Plustiveplustive
Sign inGet API key

Tutorial · Updated June 2026

Webhooks for VTU & Bill-Payment Transactions

How to receive, verify and handle Plustive webhooks: terminal states, payload shape, HMAC signature verification, retry backoff, fallback reconciliation.

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 process

Use 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 500 will trigger retries. If you receive a valid but already-processed event, return 200 — not 500.
  • 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.

FAQ

VTU webhooks — common questions.

What are the terminal states for a VTU transaction?

Success (delivered), Failed (not delivered, wallet refunded), and Refunded (explicit reversal). Pending is not terminal — do not act on it until the transaction resolves.

What happens if my webhook endpoint is down?

Plustive retries failed deliveries with exponential backoff over several hours. If all retries are exhausted without a 2xx response, the event is dropped. Use GET /api/v1/transactions/{reference} in a daily reconciliation job to catch anything missed.

How do I verify a webhook signature?

Compute HMAC-SHA256 of the raw request body using your signing secret (shown in the Plustive dashboard). Compare the result to the value in the X-Plustive-Signature header using a constant-time comparison. Reject the request if they don't match.

Can the same webhook event be delivered more than once?

Yes — network retries can cause duplicate deliveries. Your handler must be idempotent: check whether the order is already in a terminal state before updating it, and return 200 either way so Plustive stops retrying.

What should I use if I can't run a webhook server?

Poll GET /api/v1/transactions/{reference} after each purchase call, and run a reconciliation job that queries any unresolved orders. This is less efficient than webhooks but works for low-volume or serverless setups that can't maintain a persistent HTTPS endpoint.

Related guides and references: