V2 Payment API — Integration Guide
Before You Begin
Complete these prerequisites before implementing the V2 API:
Your
x-merchant-key is issued by Bonum during onboarding. Contact Bonum support if you haven't received yours. There are two environments: sandbox (testpsp.bonum.mn) and production (psp.bonum.mn). Use sandbox for all development and testing.
Before your app can generate a
PKPaymentToken, you must verify your domain with Apple and configure an Apple Payment Processing Certificate. Complete the Apple Pay setup guide →
Register with Google Pay Business Console to receive your Google Merchant ID — you will need it in the code examples below. Complete the Google Pay setup guide →
1. Overview
The V2 Payment API introduces asynchronous payment processing. Unlike V1 which waited for the bank to respond before returning a result, V2 returns immediately with a PENDING status and delivers the final result via webhook or status polling.
status: "PENDING" with a paymentId. Store this ID.awaitUrl from the response — it blocks until the bank responds and returns the final status, letting you call completePayment() within Apple Pay's 30-second window.2. Authentication
All API requests require the x-merchant-key header. Your API key is provided by Bonum and is unique to your merchant account.
| Header | Required | Description |
|---|---|---|
x-merchant-key | Required | Your merchant API key issued by Bonum |
Content-Type | Required | Must be application/json |
curl -X POST https://psp.bonum.mn/api/v2/payment/process \
-H "Content-Type: application/json" \
-H "x-merchant-key: mk_live_xxxxxxxxxxxxxxxxxxxx" \
-d '{ ... }'
| Condition | HTTP Status | Message |
|---|---|---|
| Header not sent | 401 | Request key is missing |
| Key is invalid or deactivated | 401 | Invalid or inactive request key |
3. Process Apple Pay Payment
Submit an Apple Pay payment. Pass the encrypted PKPaymentToken object exactly as received from the Apple Pay framework — do not modify or re-encode it.
| Field | Type | Required | Description |
|---|---|---|---|
order_id | string | Required | Your unique order identifier. Must be unique per merchant. |
amount | number | Optional | Payment amount in major currency units (e.g. 150.50). Min: 0.01. Max 2 decimal places. Overrides the amount in the token if provided. |
token | object | Required | The PKPaymentToken object from Apple Pay. Contains paymentData, paymentMethod, and transactionIdentifier. |
token.paymentData.data | string | Required | Base64-encoded encrypted payment data |
token.paymentData.signature | string | Required | PKCS #7 detached signature |
token.paymentData.header | object | Required | Contains publicKeyHash, ephemeralPublicKey, transactionId |
token.paymentData.version | string | Required | Encryption version, e.g. "EC_v1" |
token.transactionIdentifier | string | Required | Apple's unique transaction ID |
{
"order_id": "ORDER-2024-00123",
"amount": 150.50,
"token": {
"paymentData": {
"data": "aGVsbG8gd29ybGQ...",
"signature": "MIAGCSqGSIb3DQ...",
"header": {
"publicKeyHash": "Hmng9JBG...",
"ephemeralPublicKey": "MFkwEwYH...",
"transactionId": "9AFDA47D..."
},
"version": "EC_v1"
},
"paymentMethod": {
"displayName": "Visa 4242",
"network": "Visa",
"type": "debit"
},
"transactionIdentifier": "9AFDA47D..."
}
}
| Field | Type | Description |
|---|---|---|
paymentId | string (UUID) | Bonum's unique identifier for this payment. Store this for status polling. |
orderId | string | Your order_id echoed back |
status | string | Always "PENDING" at this stage |
acceptedAt | string (ISO 8601) | Timestamp when the request was accepted |
statusUrl | string | URL to check the current status at any time |
awaitUrl | string | URL to block and wait for the final status in real time. Recommended for completing wallet sessions. |
{
"paymentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"orderId": "ORD-20240521-001",
"status": "PENDING",
"acceptedAt": "2026-05-21T14:30:00.000+08:00",
"statusUrl": "https://psp.bonum.mn/api/v2/payments/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"awaitUrl": "https://psp.bonum.mn/api/v2/payments/a1b2c3d4-e5f6-7890-abcd-ef1234567890/await"
}
4. Process Google Pay Payment
Submit a Google Pay payment. Pass the paymentData string exactly as received from the Google Pay API.
| Field | Type | Required | Description |
|---|---|---|---|
order_id | string | Required | Your unique order identifier |
token | string | Required | The encrypted Google Pay token string from PaymentData.paymentMethodData.tokenizationData.token |
currency_code | string | Required | Currency code: MNT, USD, EUR, or JPY |
amount | number | Optional | Amount in major currency units (e.g. 150.50). Min: 0.01. |
{
"order_id": "ORDER-2024-00124",
"token": "eyJzaWduYXR1cmUiOiJNRUlDSVFDa...",
"currency_code": "MNT",
"amount": 150.50
}
Identical structure to the Apple Pay response, including awaitUrl.
{
"paymentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"orderId": "ORD-20240521-002",
"status": "PENDING",
"acceptedAt": "2026-05-21T14:31:00.000+08:00",
"statusUrl": "https://psp.bonum.mn/api/v2/payments/b2c3d4e5-f6a7-8901-bcde-f12345678901",
"awaitUrl": "https://psp.bonum.mn/api/v2/payments/b2c3d4e5-f6a7-8901-bcde-f12345678901/await"
}
5. Completing Wallet Sessions
Apple Pay and Google Pay require your app to call completePayment() (Apple) or resolve the payment (Google) within 30 seconds of the user authorizing. If you miss this window, the wallet shows a failure screen — regardless of whether the payment actually succeeded at the bank.
Because the V2 API is asynchronous, a submitted payment returns PENDING immediately. Merchants who poll GET /api/v2/payments/{paymentId} every second must write retry loops, handle timing edge cases, and risk missing the window between poll cycles. awaitUrl eliminates all of that.
Every payment response includes an awaitUrl. Calling it makes a single HTTP request that blocks on the server side until the bank responds, then returns the final status immediately. No polling loop. No retry logic. No race conditions.
paymentId and awaitUrl instantly (status: "PENDING").awaitUrl. The request holds open on the server — no polling, no retry logic on your side.AUTHORIZED or FAILED.completePayment() with the real result — well within the 30-second window.session.onpaymentauthorized = async (event) => {
try {
// Step 1: Submit — returns PENDING immediately
const submitRes = await fetch('/api/v2/payment/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-merchant-key': 'mk_live_xxxxxxxxxxxxxxxxxxxx',
},
body: JSON.stringify({
order_id: 'ORDER-2024-00123',
token: event.payment.token,
}),
});
const { awaitUrl } = await submitRes.json();
// Step 2: Wait for the real result (blocks up to 25s, returns immediately when bank responds)
const resultRes = await fetch(awaitUrl, {
headers: { 'x-merchant-key': 'mk_live_xxxxxxxxxxxxxxxxxxxx' },
});
const result = await resultRes.json();
// Step 3: Close the wallet session with the actual outcome
if (result.status === 'AUTHORIZED') {
session.completePayment(ApplePaySession.STATUS_SUCCESS);
} else {
session.completePayment(ApplePaySession.STATUS_FAILURE);
}
} catch (err) {
session.completePayment(ApplePaySession.STATUS_FAILURE);
}
};
Two identifiers appear in the setup below — make sure you use the right one: gatewayMerchantId: 'BCR2DN7TVGEYH4JB' is Bonum's static gateway ID assigned by Google — copy it exactly, do not change it. merchantId: 'YOUR_GOOGLE_MERCHANT_ID' is your own Google Merchant ID from the Google Pay Business Console — replace the placeholder with the value from your Business Profile.
// Step 1: Define the payment authorized callback
async function onPaymentAuthorized(paymentData) {
try {
// Submit to Bonum V2 API — returns PENDING immediately
const submitRes = await fetch('/api/v2/payment/process/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-merchant-key': 'mk_live_xxxxxxxxxxxxxxxxxxxx',
},
body: JSON.stringify({
order_id: 'ORDER-2024-00124',
token: paymentData.paymentMethodData.tokenizationData.token,
currency_code: 'MNT',
amount: 150.50,
}),
});
const { awaitUrl } = await submitRes.json();
// Wait for the real bank result (blocks up to 25s server-side)
const resultRes = await fetch(awaitUrl, {
headers: { 'x-merchant-key': 'mk_live_xxxxxxxxxxxxxxxxxxxx' },
});
const result = await resultRes.json();
if (result.status === 'AUTHORIZED') {
return { transactionState: 'SUCCESS' };
} else {
return {
transactionState: 'ERROR',
error: {
reason: 'PAYMENT_AUTHORIZATION',
message: result.failureReason || 'Payment was not authorized',
intent: 'PAYMENT_AUTHORIZATION',
},
};
}
} catch (err) {
return {
transactionState: 'ERROR',
error: {
reason: 'PAYMENT_AUTHORIZATION',
message: 'An unexpected error occurred',
intent: 'PAYMENT_AUTHORIZATION',
},
};
}
}
// Step 2: Initialize the client — pass the callback via paymentDataCallbacks
const paymentsClient = new google.payments.api.PaymentsClient({
environment: 'PRODUCTION', // Use 'TEST' for sandbox
paymentDataCallbacks: {
onPaymentAuthorized: onPaymentAuthorized,
},
});
// Step 3: Load payment data when the Google Pay button is clicked
function onGooglePayButtonClicked() {
paymentsClient.loadPaymentData({
apiVersion: 2,
apiVersionMinor: 0,
allowedPaymentMethods: [{
type: 'CARD',
parameters: {
allowedAuthMethods: ['CRYPTOGRAM_3DS'],
allowedCardNetworks: ['MASTERCARD', 'VISA'],
},
tokenizationSpecification: {
type: 'PAYMENT_GATEWAY',
parameters: {
gateway: 'bonumpsp',
gatewayMerchantId: 'BCR2DN7TVGEYH4JB',
},
},
}],
merchantInfo: {
merchantId: 'YOUR_GOOGLE_MERCHANT_ID', // From Google Pay Business Console
merchantName: 'Your Business Name',
},
transactionInfo: {
totalPriceStatus: 'FINAL',
totalPrice: '150.50',
currencyCode: 'MNT',
},
callbackIntents: ['PAYMENT_AUTHORIZATION'], // Required for onPaymentAuthorized to fire
});
}
If the bank has not responded within the wait window (default 25 seconds, max 28 seconds), awaitUrl returns with timedOut: true and status: "PENDING". This is rare — most payments settle within 1–3 seconds.
{
"paymentId": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"failureReason": null,
"timedOut": true
}
When timedOut is true: call completePayment(STATUS_FAILURE) to close the wallet session, then rely on your webhook for the real outcome. The payment continues processing in the background — it will not be cancelled.
| awaitUrl | Webhook | |
|---|---|---|
| Purpose | Close the live wallet session | Update your order record, send receipts |
| When it fires | On demand, real-time | Automatically, after bank responds |
| If missed | Returns timedOut: true after 25s | Retried up to 5× over 2+ hours |
| Use for | Calling completePayment() | Fulfilling orders, downstream systems |
Both run in parallel — webhook delivery does not wait for awaitUrl to finish.
6. Check Payment Status
| Status | Meaning |
|---|---|
| PENDING | Payment accepted and queued. Awaiting bank authorization. |
| Payment approved by the bank. Funds reserved. | |
| FAILED | Payment declined by the bank or a processing error occurred. Check failureReason. |
Look up a payment by its paymentId (returned when you submitted the payment).
curl https://psp.bonum.mn/api/v2/payments/550e8400-e29b-41d4-a716-446655440000 \
-H "x-merchant-key: mk_live_xxxxxxxxxxxxxxxxxxxx"
Look up a payment using your own order_id. Useful if you did not store the paymentId.
curl "https://psp.bonum.mn/api/v2/payments/lookup/by-order-id?orderId=ORDER-2024-00123" \
-H "x-merchant-key: mk_live_xxxxxxxxxxxxxxxxxxxx"
{
"paymentId": "550e8400-e29b-41d4-a716-446655440000",
"orderId": "ORDER-2024-00123",
"amount": "150.50",
"currency": "MNT",
"walletType": "APPLE_PAY",
"status": "AUTHORIZED",
"providerReference": "GBK20240415001234",
"failureReason": null,
"createdAt": "2024-04-15T10:30:00.000Z",
"updatedAt": "2024-04-15T10:30:04.000Z"
}
Note: The amount field in responses is a decimal string in major currency units (e.g. "150.50" for 150.50 MNT). No conversion needed.
7. Error Handling
| Status | Meaning | Common Cause |
|---|---|---|
200 | Success | Payment accepted (status: PENDING) |
400 | Bad Request | Missing required field, invalid currency_code, amount below minimum |
401 | Unauthorized | Missing or invalid x-merchant-key |
404 | Not Found | Payment ID or order ID does not exist |
429 | Too Many Requests | Rate limit exceeded. Wait before retrying. |
500 | Server Error | Internal processing failure — contact support with paymentId |
{
"statusCode": 400,
"message": "order_id is required",
"error": "Bad Request"
}
Submitting the same order_id twice for the same merchant returns the original payment record rather than creating a duplicate. Use unique order_id values per transaction. If a request fails at the network level (timeout, connection reset), it is safe to retry — the payment will not be double-charged.