Webhook Integration Guide

Registration

Register your webhook endpoint

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.

To register: Contact Bonum support or use the merchant dashboard to submit your HTTPS webhook URL. Bonum will provision your webhook signing secret and confirm when the endpoint is active.
What you receive
ItemProvided byNotes
Webhook URLYouThe HTTPS endpoint on your server that Bonum will POST events to
Signing secretBonumUsed 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

Overview

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 Types
EventMeaning
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.
Delivery Behavior
  • Bonum sends a POST request to your webhook URL with Content-Type: application/json
  • Your endpoint must respond with any 2xx status 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

Payload Fields
FieldTypeDescription
webhookIdstring (UUID)Use this as your idempotency key. Unique per delivery attempt. If you receive the same webhookId twice, discard the duplicate.
paymentIdstring (UUID)Bonum payment identifier (same as returned at submission)
orderIdstringYour original order_id
eventTypestringAUTHORIZED or FAILED
statusstringCurrent payment status (mirrors eventType)
amountstringAmount in major currency units as a decimal string (e.g. "150.50")
currencystringCurrency code, e.g. MNT
providerReferencestring | nullBank/acquirer reference number. Present when AUTHORIZED.
failureReasonstring | nullHuman-readable failure reason. Present when FAILED.
occurredAtstring (ISO 8601)Timestamp when the event occurred at the bank
walletTypestring | nullAPPLE_PAY or GOOGLE_PAY — the wallet used to initiate the payment.
binCategorystring | nullDOMESTIC or INTERNATIONAL — card classification based on the issuing bank's BIN. null if the card BIN could not be resolved.
AUTHORIZED Event Example
{
  "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"
}
FAILED Event Example
{
  "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

Why Verify?

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.

Important: Never skip signature verification in production. Only process payment outcomes after confirming the signature is valid.
Signature Headers
HeaderExampleDescription
X-PSP-Signaturev1=a4e8c2f1...HMAC-SHA256 signature of the payload. Format: v1=<hex_digest>
X-PSP-Timestamp1713174600Unix timestamp (seconds) when the request was sent
Verification Algorithm
  1. Read X-PSP-Timestamp and X-PSP-Signature from the request headers
  2. Reject the request if the timestamp is more than 5 minutes old (prevents replay attacks)
  3. Construct the signed message: "${timestamp}.${rawBodyJsonString}"
  4. Compute HMAC-SHA256(yourWebhookSecret, signedMessage) and hex-encode it
  5. Compare the result to the v1= value in X-PSP-Signature using a constant-time comparison
  6. Only proceed if they match
Raw body required: Compute the signature over the raw JSON bytes of the request body — not a parsed/re-serialized version. Use a raw body middleware in your framework.
Node.js Verification Example
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');
  }
}
Python Verification Example
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

Checklist
RequirementDetails
HTTPSYour webhook URL must use https://. Plain HTTP is not accepted.
Response timeRespond with a 2xx status within 10 seconds. For heavy processing, queue the event and respond immediately.
IdempotencyUse webhookId as a unique key. Store processed IDs and silently return 200 for duplicates.
Signature checkAlways verify X-PSP-Signature before processing.
No retry on successReturn 200 even if you already processed the event. Do not return 4xx for duplicates.
Recommended Pattern

For best reliability, use the store-then-process pattern:

  1. Verify signature
  2. Check webhookId is not already in your database
  3. Write the event to your database
  4. Return 200 immediately
  5. 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

Delivery Attempts

If your endpoint fails to respond with a 2xx within 10 seconds, Bonum retries the delivery with progressive backoff:

1
Immediate — sent when the payment result is ready
2
~1 minute after first failure
3
~5 minutes after second failure
4
~30 minutes after third failure
5
~2 hours after fourth failure — final attempt
After 5 failed attempts, delivery stops. The payment result will still be available via GET /api/v2/payments/{paymentId}. Contact Bonum support if you need a delivery re-triggered.
Polling as a Fallback

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"