V2 Payment API — Integration Guide

Before You Begin

Complete these prerequisites before implementing the V2 API:

1
Bonum merchant account & API key
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.
2
Apple Pay platform setup (Apple Pay integrations only)
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 →
3
Google Pay platform setup (Google Pay integrations only)
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

What is the V2 API?

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.

Payment Flow
1
Your app submits a payment request with the encrypted Apple Pay or Google Pay token.
2
The API immediately returns status: "PENDING" with a paymentId. Store this ID.
3
Bonum processes the payment asynchronously via the acquiring bank.
4
For wallet sessions (Apple Pay / Google Pay), call the 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.
5
The final result is also delivered to your webhook endpoint for backend order management and reconciliation.

2. Authentication

API Key Header

All API requests require the x-merchant-key header. Your API key is provided by Bonum and is unique to your merchant account.

HeaderRequiredDescription
x-merchant-keyRequiredYour merchant API key issued by Bonum
Content-TypeRequiredMust be application/json
Example
curl -X POST https://psp.bonum.mn/api/v2/payment/process \
  -H "Content-Type: application/json" \
  -H "x-merchant-key: mk_live_xxxxxxxxxxxxxxxxxxxx" \
  -d '{ ... }'
Authentication Errors
ConditionHTTP StatusMessage
Header not sent401Request key is missing
Key is invalid or deactivated401Invalid or inactive request key

3. Process Apple Pay Payment

POST  /api/v2/payment/process

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.

Request Body
FieldTypeRequiredDescription
order_idstringRequiredYour unique order identifier. Must be unique per merchant.
amountnumberOptionalPayment amount in major currency units (e.g. 150.50). Min: 0.01. Max 2 decimal places. Overrides the amount in the token if provided.
tokenobjectRequiredThe PKPaymentToken object from Apple Pay. Contains paymentData, paymentMethod, and transactionIdentifier.
token.paymentData.datastringRequiredBase64-encoded encrypted payment data
token.paymentData.signaturestringRequiredPKCS #7 detached signature
token.paymentData.headerobjectRequiredContains publicKeyHash, ephemeralPublicKey, transactionId
token.paymentData.versionstringRequiredEncryption version, e.g. "EC_v1"
token.transactionIdentifierstringRequiredApple's unique transaction ID
Request Example
{
  "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..."
  }
}
Response
FieldTypeDescription
paymentIdstring (UUID)Bonum's unique identifier for this payment. Store this for status polling.
orderIdstringYour order_id echoed back
statusstringAlways "PENDING" at this stage
acceptedAtstring (ISO 8601)Timestamp when the request was accepted
statusUrlstringURL to check the current status at any time
awaitUrlstringURL 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

POST  /api/v2/payment/process/google

Submit a Google Pay payment. Pass the paymentData string exactly as received from the Google Pay API.

Request Body
FieldTypeRequiredDescription
order_idstringRequiredYour unique order identifier
tokenstringRequiredThe encrypted Google Pay token string from PaymentData.paymentMethodData.tokenizationData.token
currency_codestringRequiredCurrency code: MNT, USD, EUR, or JPY
amountnumberOptionalAmount in major currency units (e.g. 150.50). Min: 0.01.
Request Example
{
  "order_id": "ORDER-2024-00124",
  "token": "eyJzaWduYXR1cmUiOiJNRUlDSVFDa...",
  "currency_code": "MNT",
  "amount": 150.50
}
Response

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

The 30-Second Problem

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.

How awaitUrl Works

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.

1
Submit the payment. Receive paymentId and awaitUrl instantly (status: "PENDING").
2
Call awaitUrl. The request holds open on the server — no polling, no retry logic on your side.
3
The server wakes the moment the bank responds and returns AUTHORIZED or FAILED.
4
Call completePayment() with the real result — well within the 30-second window.
Apple Pay Example
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);
  }
};
Google Pay Example

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
  });
}
Handling Timeouts

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 vs Webhook — When to Use Each
awaitUrlWebhook
PurposeClose the live wallet sessionUpdate your order record, send receipts
When it firesOn demand, real-timeAutomatically, after bank responds
If missedReturns timedOut: true after 25sRetried up to 5× over 2+ hours
Use forCalling completePayment()Fulfilling orders, downstream systems

Both run in parallel — webhook delivery does not wait for awaitUrl to finish.

6. Check Payment Status

Payment Status Values
StatusMeaning
PENDINGPayment accepted and queued. Awaiting bank authorization.
AUTHORIZEDPayment approved by the bank. Funds reserved.
FAILEDPayment declined by the bank or a processing error occurred. Check failureReason.
GET  /api/v2/payments/{paymentId}

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"
GET  /api/v2/payments/lookup/by-order-id

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"
Status Response
{
  "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

HTTP Status Codes
StatusMeaningCommon Cause
200SuccessPayment accepted (status: PENDING)
400Bad RequestMissing required field, invalid currency_code, amount below minimum
401UnauthorizedMissing or invalid x-merchant-key
404Not FoundPayment ID or order ID does not exist
429Too Many RequestsRate limit exceeded. Wait before retrying.
500Server ErrorInternal processing failure — contact support with paymentId
Error Response Format
{
  "statusCode": 400,
  "message": "order_id is required",
  "error": "Bad Request"
}
Idempotency

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.