Webhook Integration Guide
Registration
Webhooks are required in V2 — payments will process but you will have no way to receive the final result unless a webhook URL is registered. Complete this step before going live.
| Item | Provided by | Notes |
|---|---|---|
| Webhook URL | You | The HTTPS endpoint on your server that Bonum will POST events to |
| Signing secret | Bonum | Used to verify webhook signatures. Store it securely — never expose it client-side or commit it to source control |
Your endpoint must be publicly reachable from Bonum's servers before you can accept live payments. See Section 4 — Your Endpoint Requirements for what your server must implement.
1. How Webhooks Work
Because V2 processes payments asynchronously, the bank authorization result is not available in the initial HTTP response. Instead, Bonum delivers the final result to a webhook endpoint that you host.
When a payment completes (successfully or not), Bonum sends an HTTP POST request to your registered URL with the event payload as a JSON body.
| Event | Meaning |
|---|---|
| AUTHORIZED | The bank approved the payment. It is safe to fulfill the order. |
| FAILED | The bank declined the payment or an error occurred. Check failureReason. Do not fulfill the order. |
- Bonum sends a
POSTrequest to your webhook URL withContent-Type: application/json - Your endpoint must respond with any
2xxstatus within 10 seconds - Non-2xx responses or timeouts are treated as delivery failures and will be retried
- Up to 5 delivery attempts per event
2. Webhook Payload
| Field | Type | Description |
|---|---|---|
webhookId | string (UUID) | Use this as your idempotency key. Unique per delivery attempt. If you receive the same webhookId twice, discard the duplicate. |
paymentId | string (UUID) | Bonum payment identifier (same as returned at submission) |
orderId | string | Your original order_id |
eventType | string | AUTHORIZED or FAILED |
status | string | Current payment status (mirrors eventType) |
amount | string | Amount in major currency units as a decimal string (e.g. "150.50") |
currency | string | Currency code, e.g. MNT |
providerReference | string | null | Bank/acquirer reference number. Present when AUTHORIZED. |
failureReason | string | null | Human-readable failure reason. Present when FAILED. |
occurredAt | string (ISO 8601) | Timestamp when the event occurred at the bank |
walletType | string | null | APPLE_PAY or GOOGLE_PAY — the wallet used to initiate the payment. |
binCategory | string | null | DOMESTIC or INTERNATIONAL — card classification based on the issuing bank's BIN. null if the card BIN could not be resolved. |
{
"webhookId": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"paymentId": "550e8400-e29b-41d4-a716-446655440000",
"orderId": "ORDER-2024-00123",
"eventType": "AUTHORIZED",
"status": "AUTHORIZED",
"amount": "150.50",
"currency": "MNT",
"providerReference": "GBK20240415001234",
"failureReason": null,
"occurredAt": "2024-04-15T10:30:04.123Z",
"walletType": "APPLE_PAY",
"binCategory": "DOMESTIC"
}
{
"webhookId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentId": "7b12c830-f9d2-4a3e-b101-885544220011",
"orderId": "ORDER-2024-00124",
"eventType": "FAILED",
"status": "FAILED",
"amount": "150.50",
"currency": "MNT",
"providerReference": null,
"failureReason": "Insufficient funds",
"occurredAt": "2024-04-15T10:31:09.456Z",
"walletType": "GOOGLE_PAY",
"binCategory": "INTERNATIONAL"
}
3. Verifying Webhook Signatures
Anyone who knows your webhook URL could send fake payment events. Always verify the signature before processing a webhook to ensure it came from Bonum.
| Header | Example | Description |
|---|---|---|
X-PSP-Signature | v1=a4e8c2f1... | HMAC-SHA256 signature of the payload. Format: v1=<hex_digest> |
X-PSP-Timestamp | 1713174600 | Unix timestamp (seconds) when the request was sent |
- Read
X-PSP-TimestampandX-PSP-Signaturefrom the request headers - Reject the request if the timestamp is more than 5 minutes old (prevents replay attacks)
- Construct the signed message:
"${timestamp}.${rawBodyJsonString}" - Compute
HMAC-SHA256(yourWebhookSecret, signedMessage)and hex-encode it - Compare the result to the
v1=value inX-PSP-Signatureusing a constant-time comparison - Only proceed if they match
const crypto = require('crypto');
// Express.js — use express.raw() or bodyParser.raw() to get rawBody
app.post('/webhook/payment', express.raw({ type: 'application/json' }), (req, res) => {
try {
verifyWebhook(req.headers, req.body, process.env.WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
// Use webhookId as idempotency key
if (await isAlreadyProcessed(event.webhookId)) {
return res.status(200).send('Already processed');
}
if (event.eventType === 'AUTHORIZED') {
await fulfillOrder(event.orderId);
} else if (event.eventType === 'FAILED') {
await cancelOrder(event.orderId, event.failureReason);
}
res.status(200).send('OK');
});
function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers['x-psp-timestamp'];
const signature = headers['x-psp-signature'];
if (!timestamp || !signature) {
throw new Error('Missing signature headers');
}
// Reject if older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old — possible replay attack');
}
// Recompute expected signature
const signedMessage = `${timestamp}.${rawBody.toString()}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedMessage)
.digest('hex');
const received = signature.replace('v1=', '');
// Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex')
)) {
throw new Error('Webhook signature mismatch');
}
}
import hmac
import hashlib
import time
def verify_webhook(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers.get('X-PSP-Timestamp')
signature = headers.get('X-PSP-Signature')
if not timestamp or not signature:
raise ValueError("Missing signature headers")
# Reject if older than 5 minutes
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError("Webhook timestamp too old — possible replay attack")
# Recompute expected signature
signed_message = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
signed_message.encode('utf-8'),
hashlib.sha256
).hexdigest()
received = signature.replace('v1=', '')
# Constant-time comparison
if not hmac.compare_digest(expected, received):
raise ValueError("Webhook signature mismatch")
return True
# Django / Flask usage example:
# @app.route('/webhook/payment', methods=['POST'])
# def payment_webhook():
# raw_body = request.get_data()
# verify_webhook(request.headers, raw_body, WEBHOOK_SECRET)
# event = request.get_json()
# # process event...
# return '', 200
4. Your Endpoint Requirements
| Requirement | Details |
|---|---|
| HTTPS | Your webhook URL must use https://. Plain HTTP is not accepted. |
| Response time | Respond with a 2xx status within 10 seconds. For heavy processing, queue the event and respond immediately. |
| Idempotency | Use webhookId as a unique key. Store processed IDs and silently return 200 for duplicates. |
| Signature check | Always verify X-PSP-Signature before processing. |
| No retry on success | Return 200 even if you already processed the event. Do not return 4xx for duplicates. |
For best reliability, use the store-then-process pattern:
- Verify signature
- Check
webhookIdis not already in your database - Write the event to your database
- Return
200immediately - Process the event asynchronously (fulfill order, send email, etc.)
This keeps your response time well under 10 seconds regardless of downstream processing load.
5. Retry Schedule
If your endpoint fails to respond with a 2xx within 10 seconds, Bonum retries the delivery with progressive backoff:
GET /api/v2/payments/{paymentId}. Contact Bonum support if you need a delivery re-triggered.
If your webhook endpoint was unavailable during all retry attempts, you can recover missed events by polling:
# Check a specific payment you haven't received a webhook for
curl "https://<your-domain>/api/v2/payments/550e8400-e29b-41d4-a716-446655440000" \
-H "x-merchant-key: mk_live_xxxxxxxxxxxxxxxxxxxx"