Webhooks
Receive signed event notifications when orders, top-ups, and result-checker purchases reach a terminal state.
NetConnectGh delivers a signed POST request to your configured webhookUrl whenever an API-originated transaction reaches a terminal state. This lets your system react in real time without polling order status.
Configuration
Set your endpoint URL and rotate your signing secret from Profile → API. Both fields live on each individual API credential — different keys may target different endpoints.
Delivery contract
| Method | POST |
| Content-Type | application/json |
| User-Agent | NetConnectGh-Webhook/1.0 |
| Timeout | 15 seconds |
| Success | Any 2xx response |
| Retries | Up to 6 attempts with backoff: 1m → 5m → 30m → 2h → 12h → 12h |
| Ordering | Not guaranteed. Always reconcile by the event's stable id. |
| Idempotency key | data.orderId / txnId / rcTxnId (event-family specific) |
Request headers
POST {your webhookUrl}
Content-Type: application/json
User-Agent: NetConnectGh-Webhook/1.0
X-NetConnectGh-Timestamp: 1714305082
X-NetConnectGh-Signature: 9f3e…<hex hmac-sha256>X-NetConnectGh-Timestamp— Unix seconds when the delivery was signed.X-NetConnectGh-Signature— HexHMAC-SHA256(secret, "{timestamp}.{rawBody}").
Verifying signatures
Reject any request whose signature does not match. Use a constant-time comparison and read the raw, unparsed body — JSON re-serialization will break the signature.
import crypto from "node:crypto";
export function verifyNetConnectWebhook(req, rawBody, secret) {
const ts = req.headers["x-netconnectgh-timestamp"];
const sig = req.headers["x-netconnectgh-signature"];
if (!ts || !sig) return false;
// Optional replay protection — reject anything older than 5 minutes
const ageSec = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
if (ageSec > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(String(sig), "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_netconnect_webhook(headers, raw_body: bytes, secret: str) -> bool:
ts = headers.get("x-netconnectgh-timestamp")
sig = headers.get("x-netconnectgh-signature")
if not ts or not sig:
return False
if abs(int(time.time()) - int(ts)) > 300:
return False
signed = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)<?php
function verify_netconnect_webhook(array $headers, string $rawBody, string $secret): bool {
$ts = $headers['x-netconnectgh-timestamp'] ?? null;
$sig = $headers['x-netconnectgh-signature'] ?? null;
if (!$ts || !$sig) return false;
if (abs(time() - (int)$ts) > 300) return false;
$expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $sig);
}Use the raw request body
Express users: mount express.raw({ type: 'application/json' }) on your webhook route, not express.json(). Parsing first, then re-serializing, will produce a different byte sequence and your signature check will always fail.
Event catalog
Each terminal state fires its event family exactly once. A wallet top-up that funds an order produces only the order-side event — there is no duplicate topup.completed + order.completed pair for the same logical purchase.
| Event | Family | Trigger |
|---|---|---|
order.completed | Order | Data bundle order delivered successfully |
order.failed | Order | Data bundle order failed at the provider |
order.cancelled | Order | Order cancelled before fulfillment |
order.reversed | Order | Previously-completed order reversed by admin |
topup.completed | Order / Txn | Airtime top-up delivered (order flow) or wallet credit approved (txn flow) |
topup.failed | Order / Txn | Airtime top-up failed or wallet credit rejected |
topup.cancelled | Order | Top-up order cancelled before fulfillment |
topup.reversed | Order | Previously-completed top-up reversed |
txn.completed | Txn | Generic wallet transaction approved |
txn.failed | Txn | Generic wallet transaction rejected |
rc.completed | Result-checker | Credentials issued to recipient |
rc.failed | Result-checker | RC purchase failed |
rc.refunded | Result-checker | RC purchase refunded after delivery issue |
Payload by family
The body envelope differs slightly between families. Always switch on the top-level discriminator (event for orders, type for txns and RC).
1. Order events
Discriminator: event. Covers data bundles and airtime top-ups via the order pipeline.
{
"event": "order.completed",
"createdAt": 1714305082000,
"data": {
"orderId": "kh76twg3vzeyt0qkpqbptdhsv585pnpt",
"shortReference": "772140",
"state": "completed",
"orderType": "data_bundle",
"beneficiaryMsisdn": "0241234567",
"operatorName": "MTN",
"productName": "MTN Data",
"packageLabel": "1GB - 24hrs",
"amount": 3.9,
"currency": "GHS",
"providerRef": "PRV-...",
"paymentReference": "REF-...",
"errorCode": null,
"errorMessage": null,
"completedAt": 1714305082000,
"createdAt": 1714305000000,
"walletBalanceBefore": 250.00,
"walletBalanceAfter": 246.10
}
}| Field | Type | Notes |
|---|---|---|
event | string | One of order.completed, order.failed, order.cancelled, order.reversed, topup.completed, topup.failed, topup.cancelled, topup.reversed |
createdAt | number | Webhook-emit time (ms) |
data.orderId | string | Stable order id — use as your idempotency key |
data.shortReference | string | Human-friendly reference shared with end users |
data.state | string | completed, failed, cancelled, or reversed |
data.orderType | string | data_bundle or top_up |
data.beneficiaryMsisdn | string | Recipient phone number |
data.operatorName | string | e.g. MTN, Vodafone, AirtelTigo |
data.productName | string | Product the order was sourced from |
data.packageLabel | string | Human-readable package label |
data.amount | number | Charged amount |
data.currency | string | Always GHS today |
data.providerRef | string | null | Upstream provider reference (when available) |
data.paymentReference | string | null | Payment gateway reference (wallet flow → null) |
data.errorCode | string | null | Populated on failed |
data.errorMessage | string | null | Populated on failed |
data.completedAt | number | When the terminal transition was recorded (ms) |
data.createdAt | number | Original order creation time (ms) |
data.walletBalanceBefore | number | null | Wallet GHS balance immediately before the order was charged (agent / SuperAgent orders only) |
data.walletBalanceAfter | number | null | Wallet GHS balance after the order was charged (agent / SuperAgent orders only) |
2. Txn events (wallet top-ups & cash-outs)
Discriminator: type. Used for wallet-level transactions that are not part of a fulfillment order.
{
"type": "topup.completed",
"txnId": "j97...",
"transactionType": "top_up",
"status": "Approved",
"amount": 100,
"fee": 1,
"totalPayable": 101,
"currency": "GHS",
"paymentMethod": "momo",
"paidByPhone": "0241234567",
"referenceCode": "REF-...",
"shortReference": "...",
"paymentGatewayRef": "...",
"approvedAt": 1714305082000,
"rejectedAt": null,
"completedAt": 1714305082000,
"timestamp": 1714305082000
}| Field | Type | Notes |
|---|---|---|
type | string | topup.completed, topup.failed, txn.completed, txn.failed |
txnId | string | Stable transaction id — use as your idempotency key |
transactionType | string | e.g. top_up, cash_out |
status | string | Backend status label (Approved, Rejected, Failed) |
amount | number | Base transaction amount |
fee | number | Fee applied |
totalPayable | number | amount + fee |
currency | string | Always GHS |
paymentMethod | string | e.g. momo, card, wallet |
paidByPhone | string | null | Payer MSISDN when applicable |
referenceCode | string | Internal reference |
shortReference | string | User-friendly reference |
paymentGatewayRef | string | null | Upstream gateway reference |
approvedAt | number | null | Set on success (ms) |
rejectedAt | number | null | Set on rejection (ms) |
completedAt | number | null | Final settlement timestamp (ms) |
timestamp | number | Webhook-emit time (ms) |
3. Result-checker events
Discriminator: type. One delivery per terminal state of an RC purchase.
{
"type": "rc.completed",
"rcTxnId": "rc9...",
"state": "completed",
"packageId": "pkg_...",
"packageDisplayName": "WAEC Checker",
"amount": 17.5,
"currency": "GHS",
"paymentMethod": "wallet",
"shortReference": "421903",
"recipientPhone": "0241234567",
"credentialId": "cred_...",
"completedAt": 1714305082000,
"failedAt": null,
"refundedAt": null,
"timestamp": 1714305082000
}| Field | Type | Notes |
|---|---|---|
type | string | rc.completed, rc.failed, or rc.refunded |
rcTxnId | string | Stable RC transaction id — use as your idempotency key |
state | string | Mirrors the type suffix (completed / failed / refunded) |
packageId | string | RC package the recipient purchased |
packageDisplayName | string | Human-readable name (e.g. WAEC Checker) |
amount | number | Charged amount |
currency | string | Always GHS |
paymentMethod | string | Always wallet for API-originated RC |
shortReference | string | User-friendly reference |
recipientPhone | string | Phone the credential was issued to |
credentialId | string | null | The issued credential id (null on failed/refunded) |
completedAt | number | null | Set on rc.completed (ms) |
failedAt | number | null | Set on rc.failed (ms) |
refundedAt | number | null | Set on rc.refunded (ms) |
timestamp | number | Webhook-emit time (ms) |
Best practices
- Acknowledge fast. Respond
2xxwithin 15 seconds. Do real work asynchronously after acking. - Be idempotent. Persist a unique constraint on
orderId/txnId/rcTxnIdand ignore duplicates — retries can re-deliver the same event. - Verify, then trust. Treat the body as untrusted until the HMAC matches.
- Don't rely on order. Two events for different orders may arrive in any sequence.
- Plan for the long retry tail. A delivery rejected for 14 hours can still arrive successfully on attempt 6.
Non-2xx triggers retries
Returning 4xx does not stop the retry schedule. If you want to permanently drop a delivery, persist the orderId/txnId/rcTxnId and respond 2xx so it isn't redelivered.

