Webhooks
How your server learns a customer paid. One event today: `collection.success`.
#Set it up
- Sign in to the dashboard and open Developers → Webhooks.
- Set your URL. It must be
https, must not carry credentials, and must resolve to a public address. We check when you save it, we check again at send time, and we do not follow redirects. - Copy the signing secret shown next to it. It is created the first time you open the screen, so there is always one to copy.
- Do it once per mode. The URL and the secret are separate for test and live, so a test integration can never post into a live system.
#What we post
POST <your url>
Content-Type: application/json
X-Paymonetra-Signature: <hex HMAC-SHA512 of the raw body, keyed with your secret>
X-Paymonetra-Event: collection.success
X-Paymonetra-Delivery: 41{
"event": "collection.success",
"mode": "live",
"created_at": "2026-08-28T18:02:11+01:00",
"data": {
"reference": "20260828140900236399951256",
"amount": 12500,
"fee": 175,
"net": 12325,
"currency": "NGN",
"rail": "bank",
"customer_reference": "cus_9f21",
"customer_name": "Ngozi Nwosu",
"account_number": "8029607081",
"payer_name": "Chidera Okeke",
"paid_at": "2026-08-28T18:02:11+01:00"
}
}customer_reference is the merchant's own key, which is the whole point: they credit their user from it without reconciling anything.
#Verify the signature first
The header is X-Paymonetra-Signature: HMAC-SHA512, hex, over the raw request body, keyed with your webhook secret. Check it over the bytes you received, not over an object you decoded and re-encoded, or the digest will not match.
$secret = getenv('PAYMONETRA_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$sent = $_SERVER['HTTP_X_PAYMONETRA_SIGNATURE'] ?? '';
$expected = hash_hmac('sha512', $raw, $secret);
// Compare in constant time, and never decode the body first.
if (!hash_equals($expected, $sent)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// $event['data']['customer_reference'] is your own id for the customer.
http_response_code(200);const crypto = require('crypto')
// express.raw keeps the bytes. express.json would re-encode them and
// every signature would fail.
app.post('/paymonetra', express.raw({ type: 'application/json' }), (req, res) => {
const sent = req.get('X-Paymonetra-Signature') || ''
const expected = crypto
.createHmac('sha512', process.env.PAYMONETRA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
const a = Buffer.from(sent)
const b = Buffer.from(expected)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString())
// event.data.customer_reference is your own id for the customer.
res.sendStatus(200)
})import hmac, hashlib, os
from flask import request, abort
@app.post('/paymonetra')
def paymonetra():
raw = request.get_data() # bytes, not request.json
sent = request.headers.get('X-Paymonetra-Signature', '')
expected = hmac.new(
os.environ['PAYMONETRA_WEBHOOK_SECRET'].encode(),
raw,
hashlib.sha512,
).hexdigest()
if not hmac.compare_digest(expected, sent):
abort(401)
event = request.get_json()
# event['data']['customer_reference'] is your own id for the customer.
return '', 200Answer any 2xx. We treat 200, 201 and 204 alike.
#Retries, and why you will not get a duplicate
Delivery is attempted once immediately, then retried on a backoff of roughly 30s, 2m, 10m, 30m, 1h, 3h, 12h. Eight attempts over about a day, then it is abandoned and flagged for ops. Each event is sent once per reference, so a our bank partner redelivery, which happens about once in twenty on this rail, never becomes a second copy for the merchant.
Every delivery and retry is in the dashboard under Developers → Delivery log, with a button to send one again by hand.
#Your server being down never costs a payment
A merchant's server being unreachable never affects the payment. The money is credited either way, and the ledger write does not wait on the delivery.
#Events
Only collection.success fires today. The rest are listed so you write your handler once:
| event | live | what it is |
|---|---|---|
collection.success | yes | A customer paid. Carries your customer_reference |
payout.sent | not yet | Arrives with settlement |
payout.failed | not yet | Arrives with settlement |
refund.sent | not yet | Arrives with refunds |
A checkout payment carries checkout_reference on the same event. Null when the transfer went straight into a customer’s permanent account with no checkout behind it.