MoniWave

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.

RuleWhy
Absolute http(s) URLNo other scheme is delivered.
Publicly routable destinationOur 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 followedA 3xx response is treated as a failed delivery. Return 2xx from the endpoint itself.
HTTPS strongly recommendedhttp 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}:

completedfailedexpired
collectioncollection.completedcollection.failedcollection.expired
payoutpayout.completedpayout.failedpayout.expired
refundrefund.completedrefund.failedrefund.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-1a2b3c4d5e6f
  • t — Unix timestamp when the signature was created
  • v1 — HMAC-SHA256, hex, of {t}.{raw request body} keyed with your webhook secret

Three rules:

  1. Compute the HMAC over the raw request body bytes, exactly as received. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Compare in constant time (crypto.timingSafeEqual, hmac.compare_digest, CryptographicOperations.FixedTimeEquals) — never ==.
  3. 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:

AttemptDelay after previous
1immediate
230 seconds
32 minutes
410 minutes
530 minutes
61 hour
72 hours
84 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 2xx directly — no redirect to the real handler
  • Webhook secret stored like any other credential; rotate if exposed
  • Never trust data without verifying — treat the amount as authoritative only after the signature checks out