Webhooks
The events we send to your server (purchases, checkouts, customer deposits, payouts) and how to verify that they are really from us.
Set up
Set an https URL on your business's API & webhooks page. Each business has its own URL and signing secret, and every event carries business_id. The business page lists every webhook sent with your server's answer and a Resend button. Payouts are sent to the webhook URL of your oldest approved business that has one.
Delivery & retries
- We POST JSON to your URL. Answer with any 2xx status within 10 seconds; anything else (or a timeout) counts as a failed attempt.
- Failed attempts are retried with growing gaps (30 s, 1 min, 2 min, ... up to 1 hour apart) for up to 8 attempts.
- Your URL must be
https://, resolve to a public address, and must not redirect. - Payloads carry status only - pins and tokens are fetched through the authenticated API. Webhooks can arrive more than once or out of order: de-duplicate by
idand confirm with the GET endpoint when it matters.
Every delivery has these headers:
| Header | Value |
|---|---|
X-PowerWave-Event | The event name, e.g. checkout.paid. |
X-PowerWave-Delivery | Our delivery ID (the same when a delivery is re-sent). |
X-PowerWave-Signature | t=<unix time>,v1=<hex> - see below. |
Events
| Event | When | Docs |
|---|---|---|
transaction.successful / transaction.failed | An API purchase finished, or failed and was refunded. | Purchases |
checkout.paid / checkout.expired / checkout.failed | A checkout was paid, closed unpaid, or rejected after review. | Payments |
checkout.additional_payment | Extra money arrived on a checkout that was already paid or closed. | Payments |
collection.received | A customer paid into their permanent account number. | Payments |
payout.successful / payout.failed | A payout reached its final state. | Payouts |
ping | The "Send test event" button on your API page. | - |
Payloads
transaction.successful
A purchase finished (or was refunded: transaction.failed).
{
"id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
"event": "transaction.successful",
"created_at": "2026-10-06T19:31:04+01:00",
"business_id": 12,
"data": {
"reference": "ORDER-10293",
"transaction_ref": "PW8K2J4L9QXA",
"type": "airtime",
"status": "successful",
"amount": "970.00",
"currency": "NGN"
}
}checkout.paid
A checkout was paid and credited to your wallet (also checkout.expired, checkout.failed). The data is the checkout object.
{
"id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
"event": "checkout.paid",
"created_at": "2026-10-06T19:31:04+01:00",
"business_id": 12,
"data": {
"reference": "INV-2026-0142",
"checkout_reference": "PWCK7Q2M9XKD4RTB8WNA",
"checkout_url": "https://checkout.powerwave.ng/PWCK7Q2M9XKD4RTB8WNA",
"business_id": 12,
"source": "api",
"status": "paid",
"paid_with": "bank_transfer",
"amount": "45000.00",
"fee": "675.00",
"fee_bearer": "reseller",
"customer_pays": "45000.00",
"you_receive": "44325.00",
"bank_name": "Paga",
"account_number": "9946503468",
"customer": {
"name": "Adaeze Okafor",
"email": "adaeze@example.com",
"phone": null,
"ref": null
},
"amount_paid": "45000.00",
"note": null,
"expires_at": "2026-10-06T20:01:00+01:00",
"paid_at": "2026-10-06T19:40:12+01:00",
"created_at": "2026-10-06T19:31:00+01:00"
}
}checkout.additional_payment
More money arrived on an already paid / closed checkout. The checkout object plus the extra amount.
{
"id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
"event": "checkout.additional_payment",
"created_at": "2026-10-06T19:31:04+01:00",
"business_id": 12,
"data": {
"reference": "INV-2026-0142",
"checkout_reference": "PWCK7Q2M9XKD4RTB8WNA",
"checkout_url": "https://checkout.powerwave.ng/PWCK7Q2M9XKD4RTB8WNA",
"business_id": 12,
"source": "api",
"status": "paid",
"paid_with": null,
"amount": "45000.00",
"fee": "675.00",
"fee_bearer": "reseller",
"customer_pays": "45000.00",
"you_receive": "44325.00",
"bank_name": "Paga",
"account_number": "9946503468",
"customer": {
"name": "Adaeze Okafor",
"email": "adaeze@example.com",
"phone": null,
"ref": null
},
"amount_paid": null,
"note": null,
"expires_at": "2026-10-06T20:01:00+01:00",
"paid_at": null,
"created_at": "2026-10-06T19:31:00+01:00",
"additional_amount": "5000.00",
"additional_fee": "75.00"
}
}collection.received
A customer paid into their permanent account number.
{
"id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
"event": "collection.received",
"created_at": "2026-10-06T19:31:04+01:00",
"business_id": 12,
"data": {
"customer_ref": "STU-0042",
"account_number": "9812962181",
"amount": "20000.00",
"fee": "300.00",
"you_receive": "19700.00",
"transaction_ref": "PWC4D8K2L9QX",
"currency": "NGN"
}
}payout.successful
A payout reached its final state (or payout.failed with a failure_reason).
{
"id": "evt_3f9a1c2b7d4e5f60a1b2c3d4",
"event": "payout.successful",
"created_at": "2026-10-06T19:31:04+01:00",
"business_id": 12,
"data": {
"reference": "PAYOUT-7781",
"status": "successful",
"amount": "25000.00",
"fee": "100.00",
"currency": "NGN",
"failure_reason": null
}
}Verify the signature
Header X-PowerWave-Signature: t=1791297064,v1=<hex> where v1 = HMAC-SHA256(your_signing_secret, t + "." + raw_body). Use the raw request body (not a re-encoded copy). Reject the request if the signature does not match or t is older than 5 minutes.
<?php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_POWERWAVE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, getenv('PW_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// de-duplicate by $event['id'], then handle $event['event'] ...
http_response_code(200);// Node.js (Express: use express.raw({ type: 'application/json' }) for this route)
const crypto = require('crypto');
const parts = Object.fromEntries(req.get('X-PowerWave-Signature').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.PW_WEBHOOK_SECRET)
.update(parts.t + '.' + req.body.toString('utf8')).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
if (!fresh || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// de-duplicate by event.id, then handle event.event ...
res.sendStatus(200);Questions? Contact us.
