NetConnect API

Webhooks

Receive signed event notifications when orders, top-ups, and result-checker purchases reach a terminal state.

live·playground

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

MethodPOST
Content-Typeapplication/json
User-AgentNetConnectGh-Webhook/1.0
Timeout15 seconds
SuccessAny 2xx response
RetriesUp to 6 attempts with backoff: 1m → 5m → 30m → 2h → 12h → 12h
OrderingNot guaranteed. Always reconcile by the event's stable id.
Idempotency keydata.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 — Hex HMAC-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.

EventFamilyTrigger
order.completedOrderData bundle order delivered successfully
order.failedOrderData bundle order failed at the provider
order.cancelledOrderOrder cancelled before fulfillment
order.reversedOrderPreviously-completed order reversed by admin
topup.completedOrder / TxnAirtime top-up delivered (order flow) or wallet credit approved (txn flow)
topup.failedOrder / TxnAirtime top-up failed or wallet credit rejected
topup.cancelledOrderTop-up order cancelled before fulfillment
topup.reversedOrderPreviously-completed top-up reversed
txn.completedTxnGeneric wallet transaction approved
txn.failedTxnGeneric wallet transaction rejected
rc.completedResult-checkerCredentials issued to recipient
rc.failedResult-checkerRC purchase failed
rc.refundedResult-checkerRC 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
  }
}
FieldTypeNotes
eventstringOne of order.completed, order.failed, order.cancelled, order.reversed, topup.completed, topup.failed, topup.cancelled, topup.reversed
createdAtnumberWebhook-emit time (ms)
data.orderIdstringStable order id — use as your idempotency key
data.shortReferencestringHuman-friendly reference shared with end users
data.statestringcompleted, failed, cancelled, or reversed
data.orderTypestringdata_bundle or top_up
data.beneficiaryMsisdnstringRecipient phone number
data.operatorNamestringe.g. MTN, Vodafone, AirtelTigo
data.productNamestringProduct the order was sourced from
data.packageLabelstringHuman-readable package label
data.amountnumberCharged amount
data.currencystringAlways GHS today
data.providerRefstring | nullUpstream provider reference (when available)
data.paymentReferencestring | nullPayment gateway reference (wallet flow → null)
data.errorCodestring | nullPopulated on failed
data.errorMessagestring | nullPopulated on failed
data.completedAtnumberWhen the terminal transition was recorded (ms)
data.createdAtnumberOriginal order creation time (ms)
data.walletBalanceBeforenumber | nullWallet GHS balance immediately before the order was charged (agent / SuperAgent orders only)
data.walletBalanceAfternumber | nullWallet 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
}
FieldTypeNotes
typestringtopup.completed, topup.failed, txn.completed, txn.failed
txnIdstringStable transaction id — use as your idempotency key
transactionTypestringe.g. top_up, cash_out
statusstringBackend status label (Approved, Rejected, Failed)
amountnumberBase transaction amount
feenumberFee applied
totalPayablenumberamount + fee
currencystringAlways GHS
paymentMethodstringe.g. momo, card, wallet
paidByPhonestring | nullPayer MSISDN when applicable
referenceCodestringInternal reference
shortReferencestringUser-friendly reference
paymentGatewayRefstring | nullUpstream gateway reference
approvedAtnumber | nullSet on success (ms)
rejectedAtnumber | nullSet on rejection (ms)
completedAtnumber | nullFinal settlement timestamp (ms)
timestampnumberWebhook-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
}
FieldTypeNotes
typestringrc.completed, rc.failed, or rc.refunded
rcTxnIdstringStable RC transaction id — use as your idempotency key
statestringMirrors the type suffix (completed / failed / refunded)
packageIdstringRC package the recipient purchased
packageDisplayNamestringHuman-readable name (e.g. WAEC Checker)
amountnumberCharged amount
currencystringAlways GHS
paymentMethodstringAlways wallet for API-originated RC
shortReferencestringUser-friendly reference
recipientPhonestringPhone the credential was issued to
credentialIdstring | nullThe issued credential id (null on failed/refunded)
completedAtnumber | nullSet on rc.completed (ms)
failedAtnumber | nullSet on rc.failed (ms)
refundedAtnumber | nullSet on rc.refunded (ms)
timestampnumberWebhook-emit time (ms)

Best practices

  • Acknowledge fast. Respond 2xx within 15 seconds. Do real work asynchronously after acking.
  • Be idempotent. Persist a unique constraint on orderId / txnId / rcTxnId and 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.

On this page