VTU and bill-payment transactions don't always resolve the moment you make the call. Polling the transaction endpoint works for low volume, but webhooks are the right production pattern: Plustive POSTs your endpoint the moment a transaction reaches a terminal state, so your order is updated in real time with no polling loop. This guide covers registration, signature verification, idempotent handling, retries and fallback reconciliation.
Terminal states — the only ones you should act on
A Plustive transaction moves through at most two states:
- Pending — the purchase has been sent but the network hasn't confirmed delivery yet. Your wallet is provisionally debited. Do not act on Pending.
- Success — the delivery is confirmed. The debit stands. Show the customer their bundle or token.
- Failed — delivery ultimately failed. The wallet debit is reversed automatically. Inform the customer and cancel the order.
- Refunded — an explicit reversal was applied (rare; typically for a failed bill payment token). The wallet is credited.
Most transactions go Pending → Success within seconds. A small number take up to a minute. Plustive handles reconciliation for you — you just need to listen for the terminal webhook.
The webhook payload
When a transaction reaches a terminal state, Plustive sends an HTTP POST to your registered endpoint with a JSON body:
{
"event": "transaction.completed",
"reference": "PLS-7XKQR4N",
"clientReference": "ord_20260616_001",
"status": "Success",
"amount": 27000,
"type": "Data",
"network": "mtn",
"phone": "08030000000",
"timestamp": "2026-06-16T09:14:33Z"
}For electricity payments, a token field is included when the status is Success. For education PINs, the PIN is in the token field. Persist these immediately — they are the value your customer paid for.
Verifying the signature
Every webhook POST includes an X-Plustive-Signature header. This is an HMAC-SHA256 hex digest of the raw request body, keyed with your webhook signing secret (found in the dashboard under Webhooks). Always verify it before acting on the payload.
// Node.js / TypeScript example
import crypto from 'crypto';
function verifySignature(rawBody: string, header: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// constant-time comparison prevents timing attacks
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
// In your Express/Fastify/Next.js handler:
const rawBody = req.rawBody; // must be the unparsed string
const sig = req.headers['x-plustive-signature'] as string;
if (!verifySignature(rawBody, sig, process.env.PLUSTIVE_WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// → safe to processUse the raw body — not the parsed JSON object — for the HMAC input. JSON serialisation is not guaranteed to be stable across libraries.
Idempotent handling
Plustive retries failed deliveries, so the same event may arrive more than once. Your handler must be idempotent: before updating an order, check whether it's already in a terminal state. If it is, return 200 OK without re-processing:
async function handleWebhook(event: PlustiveEvent) {
const order = await db.orders.findByReference(event.clientReference);
if (!order) return; // unknown order — log and ignore
// already settled — acknowledge without re-processing
if (['fulfilled', 'failed', 'refunded'].includes(order.status)) return;
if (event.status === 'Success') {
await db.orders.markFulfilled(order.id, { token: event.token });
await notifyCustomer(order);
} else if (event.status === 'Failed' || event.status === 'Refunded') {
await db.orders.markFailed(order.id);
await refundCustomer(order);
}
}Retries, backoff and delivery expiry
If your endpoint returns anything other than a 2xx status, Plustive retries with exponential backoff — starting at a few seconds and widening to several hours across multiple attempts. Practical implications:
- Respond fast. Acknowledge the POST immediately with
200 OK, then process asynchronously (e.g. push to a queue). If your handler takes more than a few seconds and the connection drops, the delivery is treated as failed. - Don't return 5xx for logic errors. A
500will trigger retries. If you receive a valid but already-processed event, return200— not500. - Expect gaps in downtime. If your endpoint is unreachable for an extended period, deliveries may expire before your server recovers. This is why you need a fallback.
Fallback: reconcile against the transaction endpoint
Webhooks can be missed — your server was down, the delivery expired, or a signature mismatch caused a reject. Run a daily reconciliation job that queries any order still in a Pending state:
curl https://api.plustiveimpact.com/api/v1/transactions/PLS-7XKQR4N \
-H "Authorization: Bearer pk_live_xxx"
→ { "reference": "PLS-7XKQR4N", "status": "Success", "amount": 27000 }The transaction endpoint is the authoritative source of truth. If a webhook says Failed but the transaction endpoint says Success, trust the transaction endpoint and check your webhook logs for a delivery ordering anomaly.
The full webhook payload specification — including all event types, fields and error codes — is in the webhooks reference.