Webhooks
MoniWave POSTs a signed JSON event to your endpoint whenever a transaction reaches a terminal state, so you don't have to poll in a loop.
Webhooks are a convenience, not the source of truth. Every event is also obtainable by polling (transaction-lifecycle.md). Build so that a missed webhook is never a lost transaction.
Endpoint requirements
Your endpoint must be reachable from the public internet. MoniWave rejects a webhook URL that points at a private, loopback, link-local or internally-resolvable address, and refuses the connection at delivery time if a public hostname resolves to one of those.
| Rule | Why |
|---|---|
Absolute http(s) URL | No other scheme is delivered. |
| Publicly routable destination | Our delivery workers run inside a private network. An endpoint on 10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, fc00::/7 or a single-label/.internal/.local hostname is refused. |
| Redirects are not followed | A 3xx response is treated as a failed delivery. Return 2xx from the endpoint itself. |
| HTTPS strongly recommended | http is accepted but the signed payload then travels in clear text. |
Saving a non-public URL fails immediately with
webhook_url_not_public. A URL that passes validation but resolves
to a private address at delivery time is refused per-attempt, and the delivery log records
does not resolve to a public address as the error.
Event payload
{
"id": "0199ab3c-7d21-4e8f-9c05-1a2b3c4d5e6f",
"type": "collection.completed",
"createdAt": "2026-08-14T08:13:04.512Z",
"data": {
"id": "col_019fff54dbf37614b3420ade5ab24ae1",
"kind": "collection",
"status": "completed",
"amount": 5000,
"currency": "XAF",
"fee": 100,
"net": 4900,
"msisdn": "237690000001",
"description": "Order #1042",
"externalId": "order-1042",
"originalTransactionId": null,
"failureReasonCode": null
}
}id is the event id — stable across every retry of the same event. Use it to deduplicate.
fee and net let you reconcile straight from the webhook without a second call: fee is what we charged, net is what moved on your balance (a collection nets amount - fee, a payout nets amount + fee, a refund nets amount). On *.failed and *.expired both are null, because no money moved. They are read from the posted ledger entry, so they are identical on every retry of an event and never change after the fact.
Event types
Type is {kind}.{status}:
| completed | failed | expired | |
|---|---|---|---|
| collection | collection.completed | collection.failed | collection.expired |
| payout | payout.completed | payout.failed | payout.expired |
| refund | refund.completed | refund.failed | refund.expired |
Only terminal states produce webhooks — there is no event for accepted or submitted.
On a failed event, data.failureReasonCode carries the reason (payer_declined, insufficient_funds, invalid_subscriber, insufficient_float — see errors.md).
Verifying the signature
Always verify. An unverified webhook endpoint will accept a forged "payment completed" from anyone who finds your URL.
Each request carries:
MoniWave-Signature: t=1755158784,v1=5d41402abc4b2a76b9719d911017c592…
MoniWave-Event-Id: 0199ab3c-7d21-4e8f-9c05-1a2b3c4d5e6ft— Unix timestamp when the signature was createdv1— HMAC-SHA256, hex, of{t}.{raw request body}keyed with your webhook secret
Three rules:
- Compute the HMAC over the raw request body bytes, exactly as received. Re-serializing parsed JSON changes the bytes and breaks the signature.
- Compare in constant time (
crypto.timingSafeEqual,hmac.compare_digest,CryptographicOperations.FixedTimeEquals) — never==. - Reject timestamps outside a tolerance (~5 minutes) so a captured request cannot be replayed later.
Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('=', 2))
);
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: express.raw() gives the untouched bytes — express.json() does NOT
app.post('/webhooks/moniwave', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyWebhook(raw, req.get('MoniWave-Signature'), process.env.MONIWAVE_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
enqueueForProcessing(event); // respond fast, work later
res.sendStatus(200);
});PHP
function verify_webhook(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', $piece, 2), 2, null);
$parts[$k] = $v;
}
if (empty($parts['t']) || empty($parts['v1'])) return false;
if (abs(time() - (int)$parts['t']) > $tolerance) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input');
if (!verify_webhook($raw, $_SERVER['HTTP_MONIWAVE_SIGNATURE'], getenv('MONIWAVE_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}Python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
if "t" not in parts or "v1" not in parts:
return False
if abs(int(time.time()) - int(parts["t"])) > tolerance:
return False
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Responding
Return any 2xx to acknowledge. Anything else — or a timeout — counts as failed and triggers a retry.
Respond fast, then work. Acknowledge first and process asynchronously; slow endpoints time out and cause unnecessary retries. Requests time out after 10 seconds.
Retry schedule
Failed deliveries retry with exponential backoff over roughly 15.5 hours:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 2 hours |
| 8 | 4 hours |
After 8 attempts the delivery is exhausted and no longer retried automatically. Every attempt — status code, error, timing — is recorded and visible on the dashboard's Webhooks page, where any delivery can be redelivered manually.
An exhausted webhook never means a lost transaction: poll, and you get the same outcome.
Handling duplicates
Delivery is at-least-once, so your endpoint must be idempotent. A retry after your server accepted but failed to respond in time will arrive again.
Deduplicate on the event id:
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
await processEvent(event);
await markProcessed(event.id);Alternatively, make processing naturally idempotent — setting order.paid = true twice is harmless; incrementing a balance twice is not.
Security checklist
- Verify the signature on every request, constant-time
- HMAC over raw bytes, not re-serialized JSON
- Reject stale timestamps (~5 minutes)
- Deduplicate on event id
- Endpoint served over HTTPS, on a publicly routable host
- Endpoint answers
2xxdirectly — no redirect to the real handler - Webhook secret stored like any other credential; rotate if exposed
- Never trust
datawithout verifying — treat the amount as authoritative only after the signature checks out