Integration Guide ← Back to Sandbox

OMPAY — API Integration Guide

This guide explains how you, as a merchant, integrate with the OMPAY payment gateway to create and manage payment transactions.

One API, one integration. You work with a single OMPAY API to create and manage payments — cards, digital wallets, refunds, reversals, and status look‑ups. OMPAY handles the acquiring banks, routing, and settlement for you; there is nothing else to call.
Stable API (v1.2.0). This API is a backward‑compatible contract. Existing endpoints, parameters, response fields, status values, and error codes will not change or be removed under /api/v1 — only new optional parameters, endpoints, and response fields may be added over time. v1.2.0 is fully compatible with v1.0.0/v1.1.0: it only adds the OMPAY Wallet endpoints (§3.4) and the Corporate Dashboard disbursement endpoints (§8) — nothing from earlier versions changed. Build against the documented fields and ignore any response fields you don't recognise so future additions never break your integration. The payment links endpoints (§10) and the settlement reports endpoints (§11) are later additions of the same kind. Any breaking change would ship as a new version (/api/v2), leaving /api/v1 untouched.

1. Base URL

OMPAY runs two environments. Point your integration at the sandbox while you build and test, then change this one value to go live — nothing else about the requests differs.

EnvironmentBase URL
Sandbox (testing)https://api.sandbox.truepay.ompay.om
Production (live)https://api.truepay.ompay.om

Every endpoint below is prefixed with /api/v1 — for example https://api.sandbox.truepay.ompay.om/api/v1/transactions/bank-hosted.

Your API key and secret are issued per environment: sandbox credentials do not work in production, and production credentials do not work in the sandbox.


2. Authentication

Every request authenticates with two headers — your API key and secret:

OMPAY-API-Key:    <your_api_key>
OMPAY-API-Secret: <your_api_secret>

You receive these from OMPAY when your merchant account is set up, and can view them any time in your OMPAY Merchant Portal (each merchant has a unique key/secret; the secret can be rotated).

Your account is identified by these two headers alone — there is no merchant_id (or any account id) to send. Every request is automatically scoped to the merchant that owns the API key, and results only ever include your own transactions. You never put an account identifier in a URL, query, or body.
⚠️ Keep your API secret server‑side. Never embed it in browser, mobile, or other client‑side code. Calls should originate from your backend.

Authentication errors

HTTPerrorMeaning
401UNAUTHORIZEDMissing OMPAY-API-Key / OMPAY-API-Secret headers
401INVALID_CREDENTIALSAPI key/secret not recognised or account not ACTIVE
503SERVICE_UNAVAILABLECredential validation temporarily unavailable

3. Transaction types

There are two ways to take a payment. Pick one per transaction:

TypeEndpointWho handles the cardUse when
Merchant‑hostedPOST /api/v1/transactions/merchant-hostedYou send the card (full details *or* a saved token)You are PCI‑compliant, or you charge a stored token
Bank‑hostedPOST /api/v1/transactions/bank-hostedThe bank — customer is redirected to a 3‑D Secure / OTP pageYou want the card never to touch your servers
Wallet (Apple/Samsung Pay)POST /api/v1/transactions/wallet-payThe wallet — send the encrypted token and we decrypt it, or send decrypted fieldsAccepting Apple Pay / Samsung Pay, in-app or on your own website (see §3.3)
OMPAY WalletPOST /api/v1/transactions/ompay-wallet/send-otpThe customer — confirms an OTP sent to their phoneCharging a customer's OMPAY Wallet by phone + OTP (see §3.4)

Payment flows at a glance

flowchart LR
  subgraph MH["Merchant-hosted — you collect the card"]
    direction TB
    m1["Card entered on<br/>YOUR checkout"] --> m2["POST /transactions/merchant-hosted<br/>(card details or saved token)"]
    m2 --> m3{"3-D Secure /<br/>OTP required?"}
    m3 -->|"Yes"| m4["Redirect to<br/>OMPAY OTP page"] --> m5["OMPAY charges<br/>the bank"]
    m3 -->|"No (frictionless token)"| m5
  end
  subgraph BH["Bank-hosted — card never touches you"]
    direction TB
    b1["POST /transactions/bank-hosted<br/>(no card)"] --> b2["Redirect customer to<br/>OMPAY pay-page"] --> b3["Card entered on<br/>OMPAY's hosted page"]
    b3 --> b4{"3-D Secure /<br/>OTP required?"}
    b4 -->|"Yes"| b5["OTP on<br/>OMPAY page"] --> b6["OMPAY charges<br/>the bank"]
    b4 -->|"No"| b6
  end

The key difference is who collects the card: in *merchant‑hosted* it is entered on your checkout and sent to OMPAY; in *bank‑hosted* the customer is redirected to OMPAY's hosted page and the card never touches your servers. Either way the OTP page is OMPAY‑hosted, and whenever the customer is redirected you confirm the outcome with a server‑to‑server status inquiry (§3.9) — the redirect never carries the status.

Plus the management endpoints:

MethodPathPurpose
GET/api/v1/transactions/:id/pay-page(public) OMPAY-hosted card-entry page (bank-hosted)
GET/api/v1/transactions/:id/otp-page(public) OMPAY-hosted OTP page
GET/api/v1/transactionsList your transactions (filter + paginate)
GET/api/v1/transactions/:idFetch one transaction with its status history
POST/api/v1/transactions/apple-pay/validate-merchantApple Pay merchant validation for a button on your own website (see §3.3.1)
POST/api/v1/transactions/inquiryLook up a transaction's latest status by OMPAY id or your reference (see §3.9)
POST/api/v1/transactions/:id/refundRefund a completed transaction — full or partial, any day
POST/api/v1/transactions/:id/reverseReverse (void) a completed transaction — full amount, purchase day only
The customer stays on OMPAY pages — they never visit the bank's site. Whenever a step needs the cardholder (entering a card, or an OTP), the response gives you a OMPAY redirect_url. Send the customer there; OMPAY shows its own secure card / OTP page (collecting the OTP and submitting it to the bank via API) and, on completion, returns the customer to your return_url.
Each flow must be enabled for your merchant account. OMPAY Operations switches on the specific capabilities you're contracted for — Bank‑hosted, Merchant‑hosted (CVV+OTP / no‑CVV+OTP / no‑CVV no‑OTP), Apple Pay, Samsung Pay, and card tokenization (the vault — storing cards as tokens and charging a saved‑card token). Calling a flow that isn't enabled for you (or presenting a card BIN outside your enabled range) returns 402 MERCHANT_CAPABILITY_DISABLED — *"BIN range is not enabled for the merchant."* Ask OMPAY to enable the capability rather than retrying. In particular, if card tokenization is not enabled, POST /api/v1/tokens, GET/DELETE /api/v1/tokens/:token, and any pay‑by‑token request all return this error.
A live, interactive version of every endpoint below — with editable credentials and example bodies you can fire — is in the API Sandbox on this developer portal.

3.1 Merchant‑hosted payment

POST /api/v1/transactions/merchant-hosted

Send the card either as full details or as a saved token (one or the other, not both). The allowed authentication depends on which you send:

You sendCVVAuthentication
Full card (card)requiredCVV + 3‑D Secure (OTP) — the only flow allowed for raw card details
Token (token)never (not stored)Frictionless (no OTP) *or* 3‑D Secure (OTP), per your payment configuration

When 3‑D Secure is required you get back otp_required: true and a OMPAY redirect_url; otherwise you get the final result in one call.

sequenceDiagram
  autonumber
  participant C as Cardholder
  participant M as Your server / checkout
  participant O as OMPAY
  participant B as Bank / acquirer
  C->>M: Enters card on YOUR page
  M->>O: POST /transactions/merchant-hosted (card or token + browser)
  alt Token — frictionless (no OTP)
    O->>B: Authorize + capture
    B-->>O: Result
    O-->>M: 200 final status (SUCCESSFUL / FAILED)
  else Full card, or token needing 3-D Secure
    O-->>M: 200 otp_required + redirect_url (OMPAY OTP page)
    M-->>C: Redirect to redirect_url
    C->>O: Enters OTP on OMPAY's page
    O->>B: Submit OTP + capture
    B-->>O: Result
    O-->>C: 302 back to your return_url (ids only, no status)
    M->>O: POST /transactions/inquiry
    O-->>M: Authoritative status
  end

Request body

FieldTypeRequiredNotes
amountnumber✅> 0, max 3 decimals (OMR uses 3).
currencystring✅ISO‑4217 (e.g. OMR).
return_urlstring✖*Where the customer returns after 3‑D Secure. *Required for any flow that may need OTP (always, for full card).*
card.number / card.exp_month / card.exp_yearstring✅ (card)Required when sending a full card.
card.cvvstring✅ (card)Mandatory with full card details.
card.holder_namestring✖Cardholder name.
tokenstring✅ (token)A token from POST /api/v1/tokens. Use instead of card.
reference_numberstring✖Your order reference.
browserobject✅ (full card)The cardholder's 3‑D Secure 2 browser data. Send it with every full‑card payment — see *3‑D Secure 2 browser data* just below.
splitarray✖Split this payment across your settlement bank accounts — see *Splitting a payment* (§3.10).

3‑D Secure 2 browser data (browser)

Card payments are authenticated with 3‑D Secure 2, which requires details about the cardholder's browser. Because *you* host the checkout, the cardholder is on your page — TruePay never sees their browser and cannot collect this for you. So you collect it on your checkout page and send it in the browser object. Omitting it (or sending your own server's values instead of the cardholder's) can cause the payment to fail 3‑D Secure authentication.

browser fieldHow to obtain
userAgentnavigator.userAgent
acceptHeaderthe Accept header of the cardholder's request to your server
languagenavigator.language (e.g. en-US)
ipthe cardholder's IP address as your server sees it
colorDepthscreen.colorDepth
screenWidthscreen.width
screenHeightscreen.height
tznew Date().getTimezoneOffset()
javaEnablednavigator.javaEnabled()
javascriptEnabledtrue

All values are sent as strings (or booleans for the two …Enabled fields). For bank‑hosted payments (§3.2) you do not send this — OMPAY's hosted page collects it from the cardholder directly.

Full card (CVV + 3‑D Secure):

curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/merchant-hosted \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 12.500, "currency": "OMR", "return_url": "https://shop.example/thanks",
    "card": { "number": "4111111111111111", "exp_month": "12", "exp_year": "2028", "holder_name": "Ada Lovelace", "cvv": "123" },
    "browser": {
      "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
      "acceptHeader": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      "language": "en-US", "ip": "94.200.10.5",
      "colorDepth": "24", "screenWidth": "1920", "screenHeight": "1080", "tz": "-240",
      "javaEnabled": false, "javascriptEnabled": true
    }
  }'

Pay with a token (same endpoint, no CVV):

curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/merchant-hosted \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 9.250, "currency": "OMR", "return_url": "https://shop.example/thanks", "token": "tok_…" }'

3‑D Secure required — 200:

{ "success": true, "data": { "transaction_id": "…", "status": "PROCESSING", "otp_required": true, "redirect_url": "https://api.sandbox.truepay.ompay.om/api/v1/transactions/…/otp-page" } }

Send the customer to redirect_url; on completion they return to return_url.

Frictionless final result (token, no OTP) — 201/200:

{ "success": true, "data": { "transaction_id": "…", "status": "SUCCESSFUL", "otp_required": false, "provider_transaction_id": "…" } }

A declined card returns HTTP 200 with status: "FAILED" and an error_message (e.g. an IPAYxxxxxxx code). Missing CVV on a full card → 400 CVV_REQUIRED.


3.2 Bank‑hosted payment (redirect / 3‑D Secure)

POST /api/v1/transactions/bank-hosted

The customer enters their card and OTP on the bank's hosted page, then returns to your return_url. No card data touches your servers.

sequenceDiagram
  autonumber
  participant C as Cardholder
  participant M as Your server
  participant O as OMPAY (hosted pages)
  participant B as Bank / acquirer
  M->>O: POST /transactions/bank-hosted (amount, return_url — no card)
  O-->>M: 200 redirect_url (OMPAY pay-page)
  M-->>C: Redirect to redirect_url
  C->>O: Enters card, then OTP, on OMPAY's hosted pages
  O->>B: Authorize / submit OTP / capture (server-to-server)
  B-->>O: Result
  O-->>C: 302 back to your return_url (transaction_id, reference_number — no status)
  M->>O: POST /transactions/inquiry
  O-->>M: Authoritative status
FieldTypeRequiredNotes
amountnumber✅> 0.
currencystring✅ISO‑4217 (e.g. OMR).
return_urlstring✅Where we send the customer back after payment.
reference_numberstring✖Your order reference.
splitarray✖Split this payment across your settlement bank accounts — see §3.10.
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/bank-hosted \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" \
  -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 15.750, "currency": "OMR", "return_url": "https://shop.example/thanks", "reference_number": "ORDER-1002" }'

Response — 200 (note: redirect_url is a OMPAY URL):

{ "success": true, "data": { "transaction_id": "…", "status": "PROCESSING", "redirect_url": "https://api.sandbox.truepay.ompay.om/api/v1/transactions/…/pay-page" } }

Flow:

  1. Send the cardholder's browser to redirect_url (a OMPAY URL).
  2. OMPAY shows its own card-entry page, then its own OTP page; the OTP is

submitted to the bank via API (the customer never visits the bank).

  1. On completion OMPAY 302s the customer back to your return_url with

?transaction_id=…&amp;reference_number=… — and nothing else.

⚠️ The redirect does not carry the payment status. A status sitting in a browser URL is visible to (and forgeable by) the customer, so it must never be trusted. When the customer lands back on your return_url, call POST /api/v1/transactions/inquiry (§3.9) with the transaction_id (or reference_number) to get the authoritative outcome server‑to‑server before you fulfil the order. Don't rely on the customer returning at all — also reconcile from your backend (inquiry / reports), since the browser may never come back.

3.3 Wallet payment (Apple Pay / Samsung Pay)

POST /api/v1/transactions/wallet-pay

Merchant-hosted direct purchase for Apple Pay / Samsung Pay, routed to the wallet payment provider. There's no redirect and no OTP — the wallet's own device attestation (the cryptogram) is the 3-D Secure step.

The same endpoint serves your iOS app and your own website — an Apple Pay JS token has the same shape PassKit produces, so there is nothing different to send.

There are two ways to hand us an Apple Pay payment. Prefer the first.

A — send the encrypted token, we decrypt it (recommended). Put Apple's token object in wallet.payment_token exactly as PassKit or Apple Pay JS gave it to you. We decrypt it with the Payment Processing certificate you enrolled through the merchant portal, so no decryption code, key handling or cardholder data touches your servers:

{
  "amount": 12.500, "currency": "OMR", "reference_number": "ORDER-1003",
  "wallet": {
    "type": "APPLE_PAY",
    "payment_token": {
      "paymentData": { "data": "…", "signature": "…", "version": "EC_v1",
                       "header": { "publicKeyHash": "…", "ephemeralPublicKey": "…", "transactionId": "…" } },
      "paymentMethod": { "displayName": "Visa 1234", "network": "Visa", "type": "debit" },
      "transactionIdentifier": "…"
    }
  }
}

Don't unwrap it and don't send only paymentData — send the whole token object. Set up the certificate first: merchant portal → Apple Pay (we generate the key and CSR, you enroll it with Apple and upload the issued .cer; the private key never leaves us).

Samsung Pay works the same way: send Samsung's encrypted paymentCredential in wallet.payment_token and we decrypt it with the key we generated for your Samsung Pay service — see §3.3.2.

If you enrolled a separate Payment Processing certificate for your website, add it in the portal under *Additional Payment Processing certificates*. Each token names the certificate it was encrypted to by its public key hash, so your app's and your site's certificates both keep working. Skip this if your site reuses the app's certificate.

B — send decrypted fields. If you already decrypt the wallet token yourself, send the fields instead. Never log the cryptogram or DPAN. wallet.type is APPLE_PAY or SAMSUNG_PAY:

{
  "amount": 12.500, "currency": "OMR", "reference_number": "ORDER-1003",
  "wallet": {
    "type": "APPLE_PAY",
    "dpan": "4111111111111111", "exp_date": "2812",
    "device_manufacturer_id": "040010030273", "payment_data_type": "3DSecure",
    "cryptogram": "<base64 onlinePaymentCryptogram from PassKit>", "eci_indicator": "05",
    "display_name": "Your Store", "network": "visa"
  }
}
{
  "amount": 12.500, "currency": "OMR", "reference_number": "ORDER-1004",
  "wallet": {
    "type": "SAMSUNG_PAY",
    "token_pan": "4111111111111111", "token_pan_expiration": "2812", "utc": "20260701120000",
    "cryptogram": "<base64 cryptogram from the Samsung Pay SDK>", "eci_indicator": "07",
    "method": "3DS", "recurring_payment": false, "card_brand": "VISA", "card_last4": "1111"
  }
}

Response — 201 only when the provider result is CAPTURED; otherwise 200 with status: "FAILED":

{ "success": true, "data": { "transaction_id": "…", "status": "SUCCESSFUL", "provider_transaction_id": "…", "error_message": null } }

Refund (§3.7) and reverse (§3.8) both work on a completed wallet payment exactly like a card payment — same endpoints, same request shape.


3.3.1 Apple Pay on your own website

POST /api/v1/transactions/apple-pay/validate-merchant

▶ Download the working sample — everything below, runnable. Four files, no dependencies, Node 18+: a checkout page, the Apple Pay JS, and the two backend endpoints. Browse the source first if you prefer. There is also a native iOS sample for the in-app flow.

Apple Pay in a browser needs one step that in-app Apple Pay does not: before the payment sheet will release a token, your page must call completeMerchantValidation() with a session obtained from Apple over mutual TLS. Until that happens onpaymentauthorized never fires and there is no token to send us — if your site shows the sheet and then closes it with nothing, this is almost always why.

This endpoint performs that call for you, using your Apple merchant identifier and your Merchant Identity certificate. The certificate stays with us; nothing is downloaded and no key material goes near the browser.

Before you start

against it in your Apple Developer account. Apple enforces this — we don't check domains, and we can't register one for you.

Merchant Identity*): generate the CSR, enroll it with Apple, upload the .cer.

separate one added under *Additional Payment Processing certificates*.

Step 1 — validate the merchant. Call this from your backend, never the browser: it is authenticated with your API key.

POST /api/v1/transactions/apple-pay/validate-merchant
Authorization: <your API key>
{
  "validationURL": "https://apple-pay-gateway.apple.com/paymentservices/startSession",
  "domain": "shop.example.om"
}
FieldRequiredNotes
validationURLyesThe value Safari gives you in onvalidatemerchant. Pass it through unchanged.
domainyesThe fully-qualified host serving the Apple Pay button, e.g. shop.example.om. It must exactly match the page's host and be registered with Apple against your merchant identifier.
display_namenoOverrides the display name on the sheet. Defaults to the one on your Apple Pay settings.

The response is Apple's merchant session, verbatim — an opaque, short-lived object. Pass it to completeMerchantValidation() unchanged; don't reshape or cache it.

session.onvalidatemerchant = async (event) => {
  const r = await fetch('/your-backend/apple-pay/validate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ validationURL: event.validationURL }),
  });
  session.completeMerchantValidation(await r.json());
};

Step 2 — take the payment. Identical to your app. onpaymentauthorized gives you event.payment.token; send it to your backend and post it to wallet-pay (§3.3):

session.onpaymentauthorized = async (event) => {
  const r = await fetch('/your-backend/apple-pay/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: event.payment.token }),
  });
  const result = await r.json();
  session.completePayment(result.success
    ? ApplePaySession.STATUS_SUCCESS
    : ApplePaySession.STATUS_FAILURE);
};

Then confirm the outcome server-to-server with a status inquiry (§3.9), as with any flow.

Don't want to build this? OMPAY's bank-hosted pay-page (§3.2) already has Apple Pay in the browser, on OMPAY's own domain — you redirect and we handle validation and the sheet. No certificates, no merchant identifier, no domain registration. Ask OMPAY to enable bank-hosted checkout.

3.3.2 Samsung Pay on your app or website

Samsung Pay works exactly like Apple Pay option A above: your app or website collects the payment with Samsung's SDK, and you send us Samsung's encrypted paymentCredential unchanged. We decrypt it with a key we generated for your Samsung Pay service and charge it. No decryption code, key or card data touches your servers.

One-time setup — merchant portal → Samsung Pay (shown when TruePay has enabled Samsung Pay for your account):

  1. On the Samsung Pay portal, create your service

(Web Checkout for a website, in-app for an Android app) and copy its Service ID into the portal. For the sheet in your own page on Galaxy phones, also (or instead) create a W3C Mobile Web Payment service — see below.

  1. Click Generate key & CSR and Download CSR. We keep the private key; it never

leaves us.

  1. On the Samsung Pay portal, upload that CSR to your service and choose RSA-OAEP

(or RSA1_5) with AES-GCM. For Web Checkout, also register your website's domain. Samsung issues no certificate: it encrypts every payment credential to the public key in the CSR, so there is nothing to upload back.

  1. Click Enable Samsung Pay. A Web Checkout / in-app Service ID or a W3C Service ID,

with the key, completes the setup.

The portal shows your key's SHA-256 public-key fingerprint, so you can confirm the CSR registered with Samsung is the one we hold.

On your website (Web Checkout). Show the button only when isReadyToPay says so, open the sheet with the same amount and currency you will send us, send the credential to your server, then tell Samsung the outcome:

<script src="https://img.mpay.samsung.com/gsmpi/sdk/samsungpay_web_sdk.js"></script>
<script>
  const client = new SamsungPay.PaymentClient({ environment: 'STAGE' });   // 'PRODUCTION' when live
  const methods = { version: '2', serviceId: 'YOUR_SERVICE_ID', protocol: 'PROTOCOL_3DS',
                    allowedBrands: ['visa', 'mastercard'] };
  client.isReadyToPay(methods).then((r) => { if (r.result) showSamsungPayButton(); });

  function onSamsungPayClick() {
    client.loadPaymentSheet(methods, {
      orderNumber: 'ORDER-1005',
      merchant: { name: 'Your Store', url: 'yourstore.om', id: 'your-ref', countryCode: 'OM' },
      amount: { option: 'FORMAT_TOTAL_PRICE_ONLY', currency: 'OMR', total: 12.500 },
    }).then((paymentCredential) =>
      // Your server calls TruePay /wallet-pay with this object, unchanged (below).
      fetch('/your-server/pay', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                                  body: JSON.stringify({ paymentCredential }) })
        .then((r) => r.json())
        .then((result) => client.notify({ status: result.success ? 'CHARGED' : 'ERRED', provider: 'TruePay' })));
  }
</script>

In the page on Galaxy phones (W3C). Web Checkout sends a phone to Samsung's checkout page. Samsung's W3C Mobile Web Payment service instead opens Samsung Wallet's sheet over your own page on a Galaxy phone (Samsung Internet or Chrome, with Samsung Wallet) — the customer never leaves your checkout. Use it where it is available and keep Web Checkout as the fallback everywhere else.

  1. On the Samsung Pay portal, create a W3C Mobile Web Payment service:

phone from a country the service does not list).

TruePay is not in the list, choose NONE.

decrypts it too), in network token mode.

subdomain (pay.yourstore.om), register that subdomain — not yourstore.om. Add each host you check out on.

  1. Testing. A new service is in Debugging: only the Samsung accounts listed under its

test accounts (signed in to Samsung Wallet on the test phone) can pay, until its debug end date.

  1. Going live. Request release; Samsung verifies the service and sets it to Approved.

Until then Samsung Wallet refuses every other customer. Use the Service ID of the released service.

  1. Enter its W3C Service ID and the merchant name exactly as registered (same spelling,

case and spaces) in merchant portal → Samsung Pay → *In the page on Galaxy phones (W3C)*. The portal shows the code below with both filled in.

  1. If your checkout page sends a Content-Security-Policy, its connect-src **must list

https://spay.samsung.com**. Browsers check a payment method against connect-src; if it is missing, new PaymentRequest() throws *"payment method identifier violates Content Security Policy"* and the sheet never opens.

<script>
  function samsungPayRequest() {                 // a PaymentRequest can be shown once: make a new one each time
    return new PaymentRequest(
      [{ supportedMethods: 'https://spay.samsung.com',
         data: { version: '1', productId: 'YOUR_W3C_SERVICE_ID', merchantName: 'Name exactly as registered',
                 allowedCardNetworks: ['visa', 'mastercard'], orderNumber: 'ORDER-1005',
                 paymentProtocol: 'PROTOCOL_3DS' } }],
      { total: { label: 'Your Store', amount: { currency: 'OMR', value: '12.500' } } });
  }
  samsungPayRequest().canMakePayment().then((ok) => { if (ok) showSamsungPayButton(); /* else Web Checkout */ });

  async function onSamsungPayClick() {
    const response = await samsungPayRequest().show();          // Samsung Wallet's sheet, over this page
    const r = await fetch('/your-server/pay', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                                                body: JSON.stringify({ samsungPay: response.details }) });
    const result = await r.json();                              // your server called TruePay /wallet-pay
    await response.complete(result.success ? 'success' : 'fail');
  }
</script>

Send response.details to us unchanged as wallet.payment_token (the whole PaymentResponse is accepted too). It carries the same encrypted credential in a different shape:

{
  "amount": 12.500, "currency": "OMR", "reference_number": "ORDER-1005",
  "wallet": {
    "type": "SAMSUNG_PAY",
    "payment_token": {
      "paymentCredential": { "type": "S", "version": "100", "data": "eyJhbGciOiJSU0EtT0FFUCIs…" },
      "paymentInfo": { "card_last4digits": "1234", "cardBrand": "VI" }
    }
  }
}

In your Android app. Samsung's in-app SDK returns the same paymentCredential (3DS type) when payment succeeds on the device — send it to your server and on to us unchanged.

The request — from your server, with your API key:

{
  "amount": 12.500, "currency": "OMR", "reference_number": "ORDER-1005",
  "wallet": {
    "type": "SAMSUNG_PAY",
    "payment_token": {
      "method": "3DS", "recurring_payment": false,
      "card_brand": "VI", "card_last4digits": "1234",
      "3DS": { "type": "S", "version": "100", "data": "eyJhbGciOiJSU0EtT0FFUCIs…" }
    }
  }
}

payment_token may also be the credential as a JSON string, and payment_credential is accepted as another name for it. Send the whole object — not just 3DS.data. The amount and currency must be the ones the customer authorised in the sheet: if the credential carries a different amount or currency the payment is refused and recorded as FAILED. (If you already decrypt Samsung's credential yourself, option B in §3.3 still works.)

What can go wrong

ResponseMeaningWhat to do
400 NOT_CONFIGURED / DISABLED / NO_SERVICE_ID / NO_KEYSamsung Pay isn't set up or enabled in the portal (NO_SERVICE_ID: neither a Web Checkout / in-app nor a W3C Service ID). No transaction is created.Finish the setup above.
502 SAMSUNG_PAY_DECRYPT_FAILEDWe could not reach our decryption service. No transaction is created.Retry the same request.
FAILED with DECRYPT_FAILEDThe credential was not encrypted to your key, or is damaged.Check that the CSR registered for this Service ID — and for your W3C service — is the one in the portal (compare the fingerprint in the error message).
FAILED with WALLET_AMOUNT_MISMATCH / WALLET_CURRENCY_MISMATCHThe sheet showed a different amount or currency.Send the amount and currency you showed in the sheet.
Samsung Wallet opens and says "This app isn't supported by Samsung Pay" (W3C)Samsung refused the request for your W3C service — nothing reached us.On the Samsung Pay portal check, in this order: the service domain is the exact host of the page (a subdomain, if checkout is on one); the service is Approved — or, while it is in Debugging, the phone's Samsung account is a test account; the W3C Service ID in the merchant portal is that service's (the released one); the merchant name matches exactly; the service country includes Oman.
The phone goes to Samsung's checkout page instead of the in-page sheetThe page could not start the W3C request, so Web Checkout took over.Galaxy phone with Samsung Internet or Chrome and Samsung Wallet? https://spay.samsung.com in your page's CSP connect-src? W3C Service ID and name saved in the merchant portal?

The success response, refund (§3.7) and reverse (§3.8) are the same as for Apple Pay.

Testing without a Samsung device. On test environments, merchant portal → Samsung Pay → Simulation makes /wallet-pay settle Samsung Pay requests as a successful capture or a decline of your choice, without decrypting or charging anything. Simulation is never available in production.


3.4 OMPAY Wallet — pay by phone + OTP

The customer pays from their OMPAY Wallet (a stored‑value account) by confirming a one‑time password sent to their mobile — there is no card. Pick the flow that matches your contracted capability.

Merchant‑hosted — you collect the phone and OTP in your own UI, in two calls.

Step 1 — send the OTP. POST /api/v1/transactions/ompay-wallet/send-otp

FieldTypeRequiredNotes
amountnumber✔Amount to charge (OMR, 3 decimals).
currencystring✖Defaults to OMR.
phonestring✔The customer's mobile number (common formats accepted, e.g. 92303072 or +96892303072).
reference_numberstring✖Your own reference — echoed back and usable in /inquiry.
descriptionstring✖Free‑text description.

OMPAY generates a numeric OTP and SMSes it to the customer. The transaction stays PENDING until you submit the OTP. If the customer never completes it, OMPAY auto‑expires the transaction to FAILED after a configured timeout — start a new one.

{ "success": true, "data": { "transaction_id": "…", "reference_number": "…", "status": "PENDING", "otp_required": true } }

Step 2 — verify the OTP and charge. POST /api/v1/transactions/:id/ompay-wallet/verify

FieldTypeRequiredNotes
otpstring✔The code the customer received by SMS.

On the correct OTP the wallet is debited and the transaction becomes SUCCESSFUL (201):

{ "success": true, "data": { "transaction_id": "…", "reference_number": "…", "status": "SUCCESSFUL", "provider_transaction_id": "…", "error_message": null, "provider_error": null } }

A wrong OTP with attempts remaining returns 400 with otp_required: true — let the customer retry the same transaction. Too many wrong attempts, an expired OTP, or an expired transaction returns a terminal error with the transaction FAILED.

{ "success": false, "error": "OTP_INVALID", "message": "Incorrect OTP. Please try again.", "otp_required": true }

Example

# 1) send the OTP
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/ompay-wallet/send-otp \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 12.500, "phone": "+96892303072", "reference_number": "ORD-1001" }'

# 2) verify the OTP the customer received (:id from step 1's transaction_id)
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/<id>/ompay-wallet/verify \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "123456" }'

Bank‑hosted — OMPAY collects the phone and OTP on its own pages, so you do not call the two endpoints above. Start a normal bank‑hosted payment (POST /api/v1/transactions/bank-hosted, §3.2); if OMPAY Wallet bank‑hosted is enabled for you, an OMPAY Wallet option appears on the OMPAY hosted checkout page, and choosing it runs the phone + OTP flow on OMPAY's pages before returning the customer to your return_url. Confirm the final status from your backend with /inquiry (§3.9), exactly as for bank‑hosted cards.

Refund (§3.7) / reverse (§3.8) work on a completed OMPAY Wallet payment exactly like a card payment — same endpoints, same request shape — crediting the amount back to the customer's wallet.


3.5 List transactions

GET /api/v1/transactions

Returns your transactions, newest first. With no parameters it returns just your last 10 — page or filter to pull more, so a busy account never ships a huge payload by accident. Use page/limit to paginate and date_from/date_to to bound the window; the pagination block tells you how many pages there are.

Query parameters

ParamDefaultNotes
limit10Page size — the most recent limit transactions (max 100)
page11‑based page number
status—PENDING, PROCESSING, SUCCESSFUL, FAILED, REVERSED
date_from—Inclusive lower bound. A date (YYYY-MM-DD) starts at that day; or pass a full timestamp
date_to—Inclusive upper bound. A date (YYYY-MM-DD) includes the whole day; or pass a full timestamp

To fetch one specific transaction, use GET /api/v1/transactions/{id} (§3.6) instead — this list endpoint is only for browsing/paging many.

# most recent 10 (default)
curl -sG https://api.sandbox.truepay.ompay.om/api/v1/transactions \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET"

# page 2, 25 per page, only July, only successful
curl -sG https://api.sandbox.truepay.ompay.om/api/v1/transactions \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  --data-urlencode "page=2" --data-urlencode "limit=25" \
  --data-urlencode "date_from=2026-07-01" --data-urlencode "date_to=2026-07-31" \
  --data-urlencode "status=SUCCESSFUL"
{
  "success": true,
  "data": [ { "id": "…", "status": "SUCCESSFUL", "amount": "12.500", "currency": "OMR" } ],
  "pagination": { "total": 2, "page": 1, "limit": 10, "pages": 1 }
}

3.6 Get a transaction

GET /api/v1/transactions/:id

Returns one transaction plus its logs (status‑change history / audit trail). You may only retrieve your own transactions (others return 403).

{
  "success": true,
  "data": {
    "id": "15a09acb-…",
    "status": "SUCCESSFUL",
    "amount": "12.500",
    "currency": "OMR",
    "logs": [
      { "status": "PENDING", "message": "Transaction created.", "created_at": "…" },
      { "status": "PROCESSING", "message": "Submitted to the payment provider.", "created_at": "…" },
      { "status": "SUCCESSFUL", "message": "Provider approved the request.", "created_at": "…" }
    ]
  }
}

3.7 Refund a transaction

POST /api/v1/transactions/:id/refund

Refunds a SUCCESSFUL transaction via the bank — full or partial, on any day. This creates a new, separate transaction linked to the original via original_transaction_id (transaction_type: "REFUND") — the original transaction is never modified; it stays SUCCESSFUL forever. Refunds are capped at the amount not already refunded (cumulative across multiple partial refunds); you can call this repeatedly until the full amount is refunded.

Capability: the Refund API is enabled for your account when any Merchant‑hosted flow is enabled (or when refund is granted to you explicitly). Otherwise it returns 402 MERCHANT_CAPABILITY_DISABLED.

Request body (optional — omit amount to refund whatever remains):

{ "amount": 5.000 }

Response — 200 on success, 422 if the provider declines:

{
  "success": true,
  "data": {
    "refunded": true,
    "original_transaction_id": "…",
    "refund_transaction_id": "…",
    "refund_status": "SUCCESSFUL",
    "refund_amount": "5.000",
    "rrn": "381064972275",
    "error_message": null,
    "refundable_remaining": "7.750"
  }
}

A 400 LIMIT_EXCEEDED is returned if amount exceeds refundable_remaining. Fetch GET /api/v1/transactions/:id on the *original* transaction to see the cumulative refund_summary (amount, refunded, remaining, and every linked refund) and GET /api/v1/transactions/:id on the refund itself to see original_transaction_id.


3.8 Reverse (void) a transaction

POST /api/v1/transactions/:id/reverse

Some banks reject a refund on the purchase date and require a void instead — this endpoint voids the full amount of a SUCCESSFUL transaction, and is only accepted on the same calendar day as the purchase. From the next day onward, use /refund instead. Like a refund, this creates a new, separate transaction (transaction_type: "REVERSAL") linked via original_transaction_id — the original is never modified. Optional body:

Capability: the Reversal API is enabled together with the Refund API — when any Merchant‑hosted flow is enabled (or reversal is granted explicitly). Otherwise it returns 402 MERCHANT_CAPABILITY_DISABLED.
{ "reason": "CUSTOMER_REQUEST" }

Response — 200 on success, 422 if the provider declines (e.g. already reversed, or it's no longer the purchase date):

{
  "success": true,
  "data": {
    "reversed": true,
    "original_transaction_id": "…",
    "reversal_transaction_id": "…",
    "reversal_status": "SUCCESSFUL",
    "rrn": "TP178288751588598",
    "error_message": null
  }
}

A 400 REVERSAL_NOT_ALLOWED is returned if it's no longer the purchase date, or the transaction was already refunded/reversed.


3.9 Status inquiry

POST /api/v1/transactions/inquiry

Look up the latest status of one of your transactions by either its OMPAY transaction_id or the reference_number you supplied when creating it (send exactly one). Where a live inquiry is available, OMPAY refreshes the status from the provider first, updates its own record, and returns the current result — so this is the authoritative way to resolve a transaction that was still PENDING/PROCESSING when the customer left.

Request body (one of the two identifiers):

{ "transaction_id": "6965adeb-f21d-4f07-bc79-16bf4bf4ad0a" }
{ "reference_number": "ORDER-10506" }

Response — 200:

{
  "success": true,
  "data": {
    "transaction_id": "6965adeb-f21d-4f07-bc79-16bf4bf4ad0a",
    "reference_number": "ORDER-10506",
    "status": "SUCCESSFUL",
    "amount": 7.5,
    "currency": "OMR",
    "bin_group": "Visa Cards",
    "rrn": "000123456789",
    "provider_transaction_id": "TP178288751588598",
    "payment_id": "9900112233",
    "track_id": "TP178288751588598",
    "error_message": null,
    "token": null,
    "saved_card": null
  }
}

404 TRANSACTION_NOT_FOUND if neither identifier matches one of your transactions; 400 VALIDATION_ERROR if you send neither.

FAILED with outcome_unconfirmed: true

A payment the bank never answered — neither captured nor declined — is closed as FAILED about six minutes after it was last sent to the bank, so nothing stays open for ever. That FAILED is OMPAY's decision, not the bank's answer, and the response says so:

{ "status": "FAILED", "outcome_unconfirmed": true,
  "error_message": "Payment could not be confirmed with the bank within 6 minutes and has been closed as unsuccessful. If the customer was charged, OMPAY will review it with the bank and either confirm the payment or return the money." }

captured, OMPAY reviews it and either returns the money to the customer (the payment stays FAILED) or confirms the capture — only then does it become SUCCESSFUL, and it is settled to you in the next settlement run.

absent on an ordinary decline. The same field is on GET /api/v1/transactions/{id}.

Bank Hosted — Forced Saved Cards

If your account is enabled for Bank Hosted – Forced Saved Cards (a variant of the bank‑hosted flow), every successful bank‑hosted payment also tokenizes the card. There's nothing extra to send on /bank-hosted; once the transaction reaches SUCCESSFUL, call /inquiry and the response carries the saved‑card token so you can charge it again later via merchant‑hosted token pay:

{
  "status": "SUCCESSFUL",
  "token": "tok_a1b2c3d4e5f6",
  "saved_card": { "token": "tok_a1b2c3d4e5f6", "masked_pan": "464156******3343", "brand": "VISA" }
}

token/saved_card are null for any non‑SUCCESSFUL transaction and for ordinary (non‑forced‑saved) flows.

3.10 Splitting a payment across your settlement accounts

If OMPAY has set up more than one settlement bank account for you, every payment is settled to your primary account by default, in the proportions configured for you. You can override that for an individual payment by sending an optional split on /merchant-hosted or /bank-hosted.

split is optional and additive. If you don't send it, nothing about your integration changes.

Your accounts and their identifiers are listed in the merchant portal under Settlements. An identifier looks like sba_4f1c… and is stable — quote it exactly.

split[] fieldTypeRequiredNotes
settlement_bank_identifierstring✅One of your settlement accounts, from the portal.
percentnumber✅*Percentage of this payment. *Send percent or amount, not both.*
amountnumber✅*A fixed amount in the payment's own currency.
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/transactions/bank-hosted \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100.000,
    "currency": "OMR",
    "return_url": "https://your-shop.example/checkout/return",
    "split": [
      { "settlement_bank_identifier": "sba_4f1c9a2b7e8d1035a6c4b2e9f7d3a501", "percent": 30 }
    ]
  }'

Here 30.000 is settled to that account and the remaining 70.000 to your primary account.

Rules

whole payment.

400 INVALID_SPLIT, and nothing is charged.

never resolved against another merchant's accounts.

don't send split on a refund; it inherits the original's.

What you are actually paid. A split says where this payment's money belongs. What reaches your accounts is a *settlement*, which covers many payments and is net of fees, refunds and any balance carried from an earlier period — so the figures rarely match a single payment exactly. OMPAY divides what is payable in the proportions you asked for. Which account absorbs the difference is a setting on your account: either your primary takes it, or every account is scaled by the same factor. Your account manager can tell you which is configured, and both are visible to OMPAY on your settlement statement.


4. Transaction lifecycle

Every transaction — a payment, a refund, or a reversal — goes through the same three-state lifecycle:

PENDING ──▶ PROCESSING ──▶ SUCCESSFUL
                   │
                   └────────▶ FAILED
StatusMeaning
PENDINGCreated, not yet sent to the provider
PROCESSINGSubmitted to the payment provider
SUCCESSFULProvider approved the request
FAILEDProvider declined or a processing error occurred

A refund or reversal is its own transaction with its own lifecycle above — it is not a status the original payment transitions into. A SUCCESSFUL payment that has been fully refunded stays status: "SUCCESSFUL" forever; check its refund_summary (see §3.7) to see whether — and how much — it's been refunded.


5. Error responses

Every error shares this shape — check success: false and branch on the machine‑readable error code (the codes below are part of the frozen /api/v1 contract and will not change):

{
  "success": false,
  "error": "VALIDATION_ERROR",
  "message": "Request validation failed.",
  "details": [ { "field": "amount", "message": "amount must be a positive number greater than zero." } ]
}

message is human‑readable (and may be genericised in production for 5xx); details is present only on field validation errors; requestId is included when request tracing is enabled.

5.1 Common errors (any authenticated endpoint)

These can be returned by any endpoint below, so handle them once in your HTTP client:

HTTPerrorWhen
400INVALID_JSONRequest body is not valid JSON
401UNAUTHORIZEDMissing / malformed OMPAY-API-Key or OMPAY-API-Secret header
401INVALID_CREDENTIALSKey/secret not recognised, or the merchant account is not ACTIVE
402MERCHANT_CAPABILITY_DISABLEDThe requested flow (or the card's BIN range) isn't enabled for your account — *"BIN range is not enabled for the merchant."* Contact OMPAY to enable it
404NOT_FOUNDNo such route/path (unknown URL)
409DUPLICATE_ENTRYA transaction with that reference_number already exists
429*(rate‑limited)*Too many attempts on a hosted pay/OTP page (15 per 10 min per transaction+IP)
500INTERNAL_SERVER_ERRORUnexpected server error (detail hidden in production)
502VAULT_ERRORA downstream service (the card vault) failed
503SERVICE_UNAVAILABLECredential validation temporarily unavailable — safe to retry

5.2 Per‑endpoint errors

Success codes are shown for context; a declined payment/refund is a normal response, not an HTTP error (see the note under each). Every table also inherits the §5.1 common errors.

POST /api/v1/transactions/merchant-hosted

HTTPerrorWhen
400VALIDATION_ERRORInvalid amount; neither card nor token sent; both sent; or card.number/exp_month/exp_year missing
400CVV_REQUIREDFull card details sent without cvv (use a saved token for no‑CVV flows)
404INVALID_TOKENtoken not found in the vault
409LEGACY_TOKEN_PROFILE_MISMATCHA migrated (non-tok_) token that belongs to a different merchant account from the one configured to charge it. A configuration problem on OMPAY's side, so contact support rather than retrying.
503TOKEN_LOOKUP_UNAVAILABLEWe could not check whether this token is usable — a lookup failed, not a verdict on the token. Nothing was charged; retry.
403TOKEN_FORBIDDENtoken belongs to another merchant
4xxTOKEN_UNUSABLEtoken is revoked/unusable (status forwarded from the vault)
400INVALID_SPLITsplit is malformed, or allocates more than the payment
400UNKNOWN_SETTLEMENT_ACCOUNTA settlement_bank_identifier in split is not one of your settlement accounts
400INACTIVE_SETTLEMENT_ACCOUNTThat settlement account is not active
400SPLIT_NOT_AVAILABLESplit settlement is not enabled on your account
402MERCHANT_CAPABILITY_DISABLEDThis merchant‑hosted variant (CVV/OTP) isn't enabled
422NO_ROUTEThe card's BIN has no active route → declined
200 / 201*(success)*status: "SUCCESSFUL", or PROCESSING + otp_required: true + redirect_url. A declined card is 200 with status: "FAILED" and error_message.

POST /api/v1/transactions/bank-hosted (alias POST /api/v1/transactions/hosted)

HTTPerrorWhen
400VALIDATION_ERRORInvalid amount
400INVALID_SPLIT / UNKNOWN_SETTLEMENT_ACCOUNT / INACTIVE_SETTLEMENT_ACCOUNT / SPLIT_NOT_AVAILABLEAs for merchant‑hosted above
402MERCHANT_CAPABILITY_DISABLEDBank‑hosted flow isn't enabled
200*(success)*status: "PROCESSING" + redirect_url — send the cardholder there

POST /api/v1/transactions/wallet-pay

HTTPerrorWhen
400VALIDATION_ERRORInvalid amount, or wallet missing/malformed ({ type: "APPLE_PAY"|"SAMSUNG_PAY", … })
400NOT_CONFIGUREDYou sent wallet.payment_token but no Apple Pay certificate is set up for your account (merchant portal → Apple Pay)
400DISABLEDApple Pay is set up but switched off on your account
400NO_PSP_CERTA CSR was generated but Apple's certificate was never uploaded, so there is no key to decrypt with
400NO_MERCHANT_IDNo Apple merchant identifier is set — it is an input to the decryption, so the token cannot be opened without it
402MERCHANT_CAPABILITY_DISABLEDApple/Samsung Pay isn't enabled
422NO_ROUTEThe card's BIN has no active route → declined
502APPLE_PAY_DECRYPT_FAILEDWe could not reach the decryption service. Retryable — nothing was charged and no transaction was created.
200DECRYPT_FAILEDThe token could not be decrypted; recorded as a failed transaction, like a decline. error_message ends with the token's public key hash — compare it with the hashes under *Active certificates* in the merchant portal. No match means the token was encrypted to a certificate we don't hold, most often a separate web certificate that was never added.
200 / 201*(success)*status: "SUCCESSFUL" (or FAILED on a decline)
200*(no answer yet)*status: "PROCESSING" — the bank did not answer in time, so the outcome is not known yet. It is not a decline: do not charge the customer again. Confirm it with /inquiry (§3.9) after a minute or two; OMPAY keeps asking the bank.

POST /api/v1/transactions/apple-pay/validate-merchant — Apple Pay on your own website (§3.3.1)

HTTPerrorWhen
400VALIDATION_ERRORvalidationURL or domain missing
400BAD_VALIDATION_URLvalidationURL is not an https Apple host. Pass Safari's value through unchanged rather than constructing one.
400NO_DOMAINdomain was empty
400NOT_CONFIGURED / DISABLEDNo Apple Pay setup for your account, or it is switched off
400NO_MERCHANT_IDNo Apple merchant identifier set in the merchant portal
400NO_IDENTITY_CERTNo Merchant Identity certificate. This is the certificate that performs the call — set it up in the merchant portal (*Apple Pay → Merchant Identity*). Distinct from the Payment Processing certificate, which decrypts the payment.
402MERCHANT_CAPABILITY_DISABLEDApple Pay isn't enabled on your account
502APPLE_VALIDATION_FAILEDApple rejected the request. Nearly always the domain: it must be registered and verified against your merchant identifier, and domain must exactly match the host serving the page (www. included or excluded consistently).
502APPLE_TIMEOUT / APPLE_REQUEST_ERRORApple was unreachable. Retryable; the session is short-lived, so restart validation rather than reusing the URL.
200*(success)*Apple's merchant session, verbatim. Pass it to completeMerchantValidation() unchanged.

POST /api/v1/transactions/inquiry

HTTPerrorWhen
400VALIDATION_ERRORNeither transaction_id nor reference_number provided
404TRANSACTION_NOT_FOUNDUnknown id/reference
200*(success)*Current status + provider identifiers

POST /api/v1/transactions/:id/otp — submit the OTP for a merchant‑hosted transaction

HTTPerrorWhen
400VALIDATION_ERRORotp missing
403FORBIDDENTransaction isn't yours
4xxOTP_FAILEDWrong/expired OTP, or no OTP step pending (bank's status forwarded)
200*(success)*Final status: "SUCCESSFUL" / "FAILED"

POST /api/v1/transactions/:id/refund *(omit amount for a full refund)*

HTTPerrorWhen
402MERCHANT_CAPABILITY_DISABLEDRefunds aren't enabled for your account
404NOT_FOUNDUnknown transaction, or not yours
400NOT_REFUNDABLEThe transaction isn't a refundable SUCCESSFUL payment
400NO_PROVIDER_REFThe transaction has no provider reference to refund against
400INVALID_AMOUNTamount ≤ 0
400LIMIT_EXCEEDEDamount exceeds refundable_remaining
409REFUND_LOCKEDAnother refund on this transaction is already in flight — retry shortly
422*(bank declined)*Bank rejected the refund → success: false + data.error_message (no top‑level error; REFUND_FAILED is the fallback for an unexpected provider failure)
200*(success)*{ refunded: true, refund_amount, refundable_remaining, rrn }

POST /api/v1/transactions/:id/reverse — same‑day void of a SUCCESSFUL transaction

HTTPerrorWhen
402MERCHANT_CAPABILITY_DISABLEDReversals aren't enabled
403FORBIDDENTransaction isn't yours
400INVALID_TRANSACTION_IDBlank / missing transaction id
404TRANSACTION_NOT_FOUNDUnknown transaction
400NOT_REVERSIBLENot a SUCCESSFUL payment (only a payment can be voided)
400NO_PROVIDER_REFThe transaction has no provider reference to void
400REVERSAL_NOT_ALLOWEDNo longer the purchase day, or already refunded/reversed — use /refund instead
422*(bank declined)*Bank rejected the void → success: false + data.error_message
200*(success)*{ reversed: true, reversal_transaction_id }

GET /api/v1/transactions (list) · GET /api/v1/transactions/:id (get)

HTTPerrorWhen
400INVALID_FILTERA list filter is invalid (bad date range, status, or paging) — *list only*
404TRANSACTION_NOT_FOUNDUnknown id — *get only*
403FORBIDDENThe transaction belongs to another merchant — *get only*
200*(success)*List: { data: [...], pagination }. Get: the transaction (with refund_summary on refundable ones)

POST /api/v1/tokens (tokenize a card)

HTTPerrorWhen
400VALIDATION_ERRORcard_number, exp_month, or exp_year missing
400VAULT_ERRORCard number fails Luhn, or the expiry is invalid
402MERCHANT_CAPABILITY_DISABLEDCard tokenization isn't enabled
201*(success)*{ token, brand, last4, masked_pan, status: "ACTIVE", … }

GET /api/v1/tokens/:token (masked metadata)

HTTPerrorWhen
404VAULT_ERRORUnknown token — *"Token not found."*
404TOKEN_NOT_FOUNDThe token belongs to a merchant outside your group (see *Shared tokens* below)
402MERCHANT_CAPABILITY_DISABLEDCard tokenization isn't enabled
502VAULT_ERRORVault unavailable
200*(success)*{ token, status: "ACTIVE"|"REVOKED", masked_pan, … }

DELETE /api/v1/tokens/:token (revoke)

HTTPerrorWhen
404VAULT_ERROR*"Token not found or already revoked."* Not idempotent — a repeat DELETE on an already‑revoked token returns 404, and this response can't distinguish "already revoked" from "never existed."
404TOKEN_NOT_FOUNDToken belongs to a merchant outside your group
403TOKEN_NOT_YOURSThe token is shared with you by another merchant in your group — only its owner or your main merchant can revoke it
402MERCHANT_CAPABILITY_DISABLEDCard tokenization isn't enabled
502VAULT_ERRORVault unavailable
200*(success)*{ data: { …, status: "REVOKED" } } — synchronous; no async/pending state. Only ACTIVE and REVOKED statuses exist. Treat both this 200 and the 404 "…already revoked" as *"token is now revoked."*

Shared tokens (merchant groups)

If your business trades under several merchant accounts with OMPAY — a main account and one or more sub-merchants — OMPAY can switch on token sharing for the group. Ask your account manager; it is off unless you request it.

With it on, every merchant in the group can use every other member's saved cards, so a customer who saved their card with one of your brands does not have to enter it again at another:

"shared_from_group": true so you can tell it was saved by a sibling account.

whichever merchant account made it, not to the one that saved the card.

main account, can revoke it. A sibling attempting it gets 403 TOKEN_NOT_YOURS. Ask the main account to revoke on a cardholder's behalf.

against the acquiring account of the merchant that owns them, because that is the only account the token exists in.

Nothing about ownership changes: a token always belongs to the merchant that created it, so if sharing is switched off later, every token simply answers to its owner again.


6. Sandbox / test behaviour

simple, deterministic smoke test for wiring up auth and reading back a status: any amount whose value ends in 00 (e.g. 100, 200, 1500.00 → "1500") is declined (FAILED); any other amount (e.g. 12.500, 99.900) succeeds (SUCCESSFUL). It requires a merchant‑hosted capability, so an account without one gets 402 MERCHANT_CAPABILITY_DISABLED. Use it to exercise create → list → status; to test refunds and reversals, create a real card payment with merchant‑hosted (§3.1) or bank‑hosted (§3.2) first — those are the transactions §3.7/§3.8 act on.

bank integration** — the result reflects what the bank actually returns for the card/amount you send, and these ARE the transactions you refund/reverse in §3.7/§3.8.

and the acquiring bank is never exposed to you. OMPAY routes the card internally from its BIN. The transaction instead carries neutral, provider‑agnostic fields: the bin_group (e.g. "Local Debit Cards"), the payment_instrument card type (e.g. "Debit Card" / "Credit Card"), and reconciliation identifiers (rrn, provider_transaction_id, payment_id, track_id). (Bank‑hosted redirects to the 3‑D Secure page, since the PAN isn't known until the customer enters it there.)

Test cards

Use these cards in the sandbox to run card payments end‑to‑end. Sandbox only — they move no real money.

With OTP (3‑D Secure) — completes through the OTP step:

FieldValue
Card number4641560030953343
Expiry date05/32
CVV878
OTP1234

Without OTP — completes without a 3‑D Secure / OTP step:

FieldValue
Card number4111111111111111
Expiry date12/30
CVV123

7. Quick start

  1. Get your API key and secret from your OMPAY Merchant Portal — these identify your account; there is no merchant id to send.
  2. Try the call in §3.1 with curl.
  3. Or fire any endpoint live — with editable credentials and ready‑to‑send example requests — from the API Sandbox on this developer portal.

Copy‑paste Node.js and Python examples are below.

Node.js (built‑in fetch, Node 18+)

// Bank-hosted: get a redirect_url to send the cardholder to.
const res = await fetch('https://api.sandbox.truepay.ompay.om/api/v1/transactions/bank-hosted', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'OMPAY-API-Key': process.env.OMPAY_API_KEY,
    'OMPAY-API-Secret': process.env.OMPAY_API_SECRET,
  },
  body: JSON.stringify({
    amount: 12.5, currency: 'OMR',
    return_url: 'https://shop.example/thanks',
    reference_number: 'ORDER-1001',
  }),
});
const { data } = await res.json();
console.log(data.status, data.redirect_url); // redirect the customer here

Python (requests)

import os, requests
r = requests.post("https://api.sandbox.truepay.ompay.om/api/v1/transactions/bank-hosted",
    headers={"OMPAY-API-Key": os.environ["OMPAY_API_KEY"],
             "OMPAY-API-Secret": os.environ["OMPAY_API_SECRET"]},
    json={"amount": 12.5, "currency": "OMR",
          "return_url": "https://shop.example/thanks",
          "reference_number": "ORDER-1001"})
print(r.json()["data"]["redirect_url"])

8. Disbursements (Corporate Dashboard)

*Available only to merchants enabled for the Corporate Dashboard — a prefunded OMR balance you hold with OMPAY, topped up by bank transfer and credited by OMPAY after verification. If it isn't enabled for you, these endpoints return 402 MERCHANT_CAPABILITY_DISABLED. Ask OMPAY to enable it.*

Pay out from your corporate balance to your customers' OMPAY wallets — one at a time or in bulk. Each disbursement debits your balance and credits the customer's wallet; if your balance is insufficient the disbursement is rejected. Give every disbursement a unique reference — a repeat with the same reference is rejected, so a retry can never pay a customer twice. Phone numbers are Oman mobiles and are always handled in the form +968XXXXXXXX: you may send 9XXXXXXX, 968…, 00968… or +968… (spaces and dashes are ignored) and the number is normalised; anything that is not an 8‑digit Oman mobile is rejected with 400 VALIDATION_ERROR. Phone numbers are returned masked as +968****XXXX.

OMPAY's fee. If your agreement includes a disbursement fee, it is taken from your balance with each disbursement, plus VAT at the prevailing rate — the balance must cover the amount, the fee and the VAT, or the disbursement is rejected and nothing is taken. The fee is fixed when you submit the disbursement and is shown on each row as fee and fee_vat (both 0 when no fee applies). A disbursement that ends FAILED returns its fee and VAT with the amount. Collections carry no fee. In the rare case the fee cannot be worked out, the call is refused with 503 FEE_CONFIG_UNAVAILABLE and nothing is created — retry shortly.

Wallet credits and debits are paced platform‑wide at 50 per second (half the wallet's own limit). Bulk batches drain at the worker's pace; in the rare case that the pacing queue is full, a single call returns status: PENDING with a "queued" message and the worker completes it — poll GET /api/v1/disbursements/{batch_id} (or /collections/{batch_id}).

8.1 Single disbursement

POST /api/v1/disbursements — processed synchronously; the response carries the final status.

FieldTypeRequiredNotes
phonestring✅Customer's Oman mobile as +968XXXXXXXX (e.g. +96891234567); other spellings of the same number are accepted and normalised.
amountnumber✅OMR, > 0, up to 3 decimals.
referencestring✖Your unique reference (≤ 100 chars). Auto‑generated if omitted — supply it for safe retries.
descriptionstring✖Free text.
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/disbursements \
  -H "OMPAY-API-Key: $OMPAY_API_KEY" -H "OMPAY-API-Secret: $OMPAY_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+96890000000", "amount": 10.000, "reference": "PAYOUT-1001" }'

201 on success:

{ "success": true, "data": {
  "batch_id": "…",
  "disbursement": { "disbursement_id": "…", "reference": "PAYOUT-1001", "phone": "+968****0000",
    "amount": 10.000, "currency": "OMR", "status": "SUCCESSFUL", "wallet_reference": "…", "error_message": null,
    "fee": 0.250, "fee_vat": 0.013 }
}}

status is SUCCESSFUL; FAILED (with error_message — e.g. insufficient balance, or the credit was declined); or, rarely, PROCESSING (awaiting confirmation from the wallet provider — poll §8.3).

8.2 Bulk disbursement

POST /api/v1/disbursements/bulk — send up to 10,000 disbursements in one call. Every row is stored first, then processed one by one in the background; the call returns immediately with a batch you poll (§8.3) for per-row results.

{ "disbursements": [
  { "phone": "+96890000000", "amount": 10.000, "reference": "PAYOUT-1001", "description": "October" },
  { "phone": "+96890000001", "amount": 25.500, "reference": "PAYOUT-1002" }
] }

Rows that fail validation or reuse a reference are reported and excluded (the rest are queued). A reference whose earlier row ended FAILED may be submitted again: the failed attempt is kept in its batch for your records (its reference gains a ~F… suffix) and the new row carries the reference you sent:

{ "success": true, "data": { "batch_id": "…", "total": 2, "queued": 2, "errors": [] } }

8.3 Batch status + per‑row results

GET /api/v1/disbursements/:batch_id — poll until no row is PENDING/PROCESSING.

{ "success": true, "data": {
  "batch": { "batch_id": "…", "source": "API", "status": "COMPLETED",
    "total_count": 2, "success_count": 2, "failed_count": 0, "processing_count": 0, "total_amount": 35.500 },
  "disbursements": [
    { "disbursement_id": "…", "reference": "PAYOUT-1001", "phone": "+968****0000", "amount": 10.000, "status": "SUCCESSFUL", "wallet_reference": "…", "fee": 0.250, "fee_vat": 0.013 }
  ]
}}

Row status: PENDING → PROCESSING → SUCCESSFUL / FAILED. A rare NEEDS_REVIEW means the debit was taken but the wallet credit could not be confirmed — OMPAY reconciles these; do not re‑send under a new reference until you've confirmed the outcome.

8.4 Collections — debit customer wallets (needs the *Debit customer wallets* capability)

The mirror image of disbursements: take an amount from a customer's OMPAY wallet into your corporate balance. Same request and response shapes as §8.1–8.3 with the noun collection(s), under POST /api/v1/collections, POST /api/v1/collections/bulk and GET /api/v1/collections/:batch_id. Requires the Corporate Dashboard capability and the separate *Debit customer wallets (Collections)* toggle set by OMPAY — otherwise 402 MERCHANT_CAPABILITY_DISABLED.

Money moves in the opposite order: the customer's wallet is debited first, and your corporate balance is credited only once the wallet confirms the debit. A decline (for example an insufficient customer balance) leaves both sides untouched — status: FAILED with a plain error_message. If the wallet's answer cannot be obtained after retries, the row ends as NEEDS_REVIEW with nothing credited to your balance until OMPAY reconciles it.

curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/collections \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET" -H "Content-Type: application/json" \
  -d '{ "phone": "+96890000000", "amount": 12.500, "reference": "INV-1001", "description": "October subscription" }'
{ "success": true,
  "data": { "batch_id": "…", "idempotent": false,
    "collection": { "collection_id": "…", "direction": "DEBIT", "reference": "INV-1001",
      "wallet_txn_reference": "COLL-…", "phone": "+968****0000", "amount": 12.5, "currency": "OMR",
      "status": "SUCCESSFUL", "wallet_reference": "…", "error_message": null, "created_at": "…" } } }

Bulk takes { "collections": [ { "phone", "amount", "reference", "description" }, … ] } (or a CSV upload in the merchant portal's Corporate Dashboard → Collections (Debit) page with the same columns as the disbursement template). Idempotency-Key and the (merchant, reference) uniqueness work exactly as for disbursements; a reference already used for a disbursement cannot be reused for a collection. wallet_txn_reference (COLL-&lt;id&gt;) is the reference that appears on the customer's wallet transaction.


9. Customer wallet check

*Needs the Customer Wallet Check capability. Without it every call returns 402 MERCHANT_CAPABILITY_DISABLED. Ask OMPAY to enable it.*

Ask whether a customer already holds an OMPAY wallet before you try to pay them or collect from them — and, when you can produce that customer's identity document, whether the account is in a state that can transact.

Nothing is charged, nothing is stored about the customer, and the call moves no money. Because it answers a question about a named person, it is rate limited to 20 calls a minute and 500 a rolling day per merchant, and every call is recorded in OMPAY's audit trail.

9.1 Check an account

POST /api/v1/customers/wallet-check

FieldTypeRequiredNotes
phonestring✅The customer's Oman mobile as +968XXXXXXXX. Other spellings of the same number (9XXXXXXX, 968…, 00968…, spaces, dashes) are accepted and normalised.
id_numberstring✅The customer's identity document — civil ID or passport number, 4–32 characters.
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/customers/wallet-check \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET" -H "Content-Type: application/json" \
  -d '{ "phone": "+96891234567", "id_number": "12345678" }'
{ "success": true,
  "data": {
    "found": true,
    "identity_match": true,
    "status": "ACTIVE",
    "active": true,
    "phone": "+968****4567",
    "reason": null,
    "reason_code": null,
    "checked_at": "2026-09-16T08:41:02.113Z" } }
FieldMeaning
foundAn OMPAY customer is registered with that mobile number.
identity_matchThe identity document you sent belongs to that same customer.
statusACTIVE, INACTIVE, SUSPENDED, BLOCKED, CLOSED, PENDING or UNKNOWN — only when identity_match is true, otherwise null.
activetrue / false when a status was returned; null when no status was — "we did not say" is not the same as "cannot transact".
phoneThe number you asked about, masked.
reasonPlain-language explanation whenever status is null; null when a status was returned.
reason_codeThe same thing for code: NOT_FOUND, IDENTITY_MISMATCH (the document belongs to someone else — check what you hold) or IDENTITY_UNVERIFIABLE (OMPAY could not check it); null when a status was returned.
status_detailPresent only when status is UNKNOWN — the state as the directory worded it.

identity_match is deliberately conservative: it is true only when the directory itself confirms the document belongs to that customer. If the directory gives OMPAY no way to check, the answer is identity_match: false with a reason saying so, and no status — never an optimistic match.

Why a status can be withheld. The account state is disclosed only to a caller who can produce that customer's identity document. So a mobile that is registered to somebody else answers, honestly and without telling you anything about them:

{ "success": true,
  "data": { "found": true, "identity_match": false, "status": null, "active": false,
    "phone": "+968****4567",
    "reason": "An OMPAY customer is registered with this mobile number, but the identity document does not belong to them.",
    "reason_code": "IDENTITY_MISMATCH",
    "checked_at": "…" } }

9.2 Errors

StatuserrorMeaning
400VALIDATION_ERRORThe mobile is not an Oman mobile, or the identity document is missing/implausible.
402MERCHANT_CAPABILITY_DISABLEDThe capability is not enabled for you.
429RATE_LIMITEDMore than 20 checks in a minute.
429DAILY_LIMIT_REACHEDMore than 500 checks in a rolling 24 hours.
502ANALYTICS_BAD_RESPONSEThe customer directory replied with something unreadable.
503ANALYTICS_NOT_CONFIGURED / ANALYTICS_DISABLED / ANALYTICS_REJECTEDChecks are unavailable in this environment.
504ANALYTICS_UNAVAILABLEThe directory did not answer in time.
503CHECK_NOT_RECORDED / CHECK_QUOTA_UNAVAILABLEOMPAY could not record the check, so it is not disclosed. Retry.

Treat every 5xx as "unknown", never as "no such customer". A directory that is down, slow or misconfigured always produces an error — it never produces found: false. If you branch on this call, fail the branch open (carry on and let the payment itself decide) rather than telling a real customer they have no wallet.

9.3 Test numbers (sandbox)

When the sandbox runs on simulated data, the last two digits of the mobile choose the answer:

Mobile endsAnswer
…22found, identity matches, SUSPENDED
…33found, identity does not match — no status
…44not found
…55found, identity matches, PENDING
…66found, but the identity cannot be verified — no status
…99directory unavailable (504)
anything else (e.g. …11)found, identity matches, ACTIVE

*Needs the Payment Links capability. Without it every call returns 402 MERCHANT_CAPABILITY_DISABLED. Ask OMPAY to enable it.*

A payment link is a web address you give a customer. They open it, pay on OMPAY's hosted checkout page (card details and 3‑D Secure are handled there, never by you) and are sent back to you. You do not build a payment page, and no card data ever reaches your server.

It is the same feature as Payment Links in the OMPAY Merchant Portal: a link you create here appears there, and the other way round.

EnvironmentEndpoint
Sandboxhttps://api.sandbox.truepay.ompay.om/api/v1/payment-links
Productionhttps://api.truepay.ompay.om/api/v1/payment-links

Authentication is the same two headers as everywhere else (§2).

10.1 In three steps

1. Create a link. Only amount is required.

curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/payment-links \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET" -H "Content-Type: application/json" \
  -d '{ "amount": "12.500" }'
{ "success": true,
  "data": {
    "id": "5f0c1c9e-2b7a-4c53-9f3e-0d6a7c1b2e44",
    "status": "PENDING",
    "amount": "12.500",
    "currency": "OMR",
    "pay_url": "https://store.truepay.ompay.om/pay/Xk3...",
    "qr_url": "https://store.truepay.ompay.om/pay/Xk3.../qr.png",
    "expires_at": "2026-10-28T10:00:00.000Z",
    "created_at": "2026-10-25T10:00:00.000Z",
    "delivered": {} } }

2. Give it to your customer. Send them pay_url (a message, an email, a button), or show qr_url (the same address as a QR code image) for them to scan with their phone.

3. Find out when it is paid. Read the link: GET /api/v1/payment-links/{id}. When status is PAID, transaction_id is the payment — look it up with GET /api/v1/transactions/{transaction_id} (§3.6).

That is the whole flow. Everything below is optional.

POST /api/v1/payment-links

FieldTypeRequiredNotes
amountstring or number✅In OMR, at most 3 decimals: "12.500".
descriptionstringWhat it is for. The customer sees it. Up to 255 characters.
referencestringYour own reference (an order number). Up to 80 characters. Each reference can be used for one payment only.
customer_namestringUp to 150 characters.
customer_emailstringNeeded if you want OMPAY to email the link.
customer_phonestringNeeded if you want OMPAY to text the link. 91234567, +96891234567 and 0096891234567 are all accepted.
expiry_hoursintegerHow long the link stays valid, 1 to 8784. Or send expires_at (below). Neither: the default (§10.8).
expires_atstringA date and time (ISO 8601, e.g. 2026-12-31T20:00:00Z), in the future and within a year. Send this or expiry_hours, not both.
allow_partialbooleantrue lets the customer pay in instalments until the link is settled. A link that allows partial payment cannot carry a reference.
return_urlstringWhere the customer is sent after paying. An https:// (or http://) address.
notify_via_emailbooleanEmail the link to customer_email now.
notify_via_smsbooleanText the link to customer_phone now.
curl -X POST https://api.sandbox.truepay.ompay.om/api/v1/payment-links \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET" -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{ "amount": "45.000", "description": "Order 1042", "reference": "ORD-1042",
        "customer_name": "Aisha", "customer_email": "aisha@example.com", "customer_phone": "91234567",
        "expiry_hours": 48, "return_url": "https://shop.example.com/thanks",
        "notify_via_email": true, "notify_via_sms": true }'

delivered in the answer says what happened to each message: { "EMAIL": "sent", "SMS": "failed" }. A message that could not be sent never loses the link: you always get pay_url back, and you can send again (§10.5). Emails go out under your own sender address when you have set one up and verified it in the portal.

Retrying safely. Send an Idempotency-Key header (any string up to 80 characters, one per link you mean to make). If your request times out and you send it again with the same key, you get the same link back instead of a second one. Without the header every call makes a new link.

Every call that returns a link returns these fields:

FieldMeaning
idThe link's id. Use it in the other calls.
statusPENDING (waiting for payment), PARTIALLY_PAID, PAID, EXPIRED or CANCELLED.
amount, paid_amount, remaining_amount, currencyMoney, 3 decimals. paid_amount and remaining_amount matter when allow_partial is on.
description, reference, customer_name, customer_email, customer_phoneWhat you sent, or null.
allow_partialWhether instalments are allowed.
pay_urlThe page the customer pays on.
qr_urlThe same address as a QR code image (PNG).
return_urlWhere the customer is sent after paying, or null.
expires_at, created_at, paid_atTimes, ISO 8601 (UTC). paid_at is null until paid.
transaction_idThe payment, once there is one (§3.6). null until then.
created_byWho made it: api, or a person's email when it was made in the portal or the OMPAY Business app.
batch_idThe bulk batch it came from (§10.7), or null.

Reading one link (GET /api/v1/payment-links/{id}) also returns deliveries: each message sent (channel, recipient, status, sent_at).

A link expires on its own at expires_at; a paid link stays paid. The customer who opens a link that is no longer payable sees a page that says so.

10.4 Read, list and cancel

CallWhat it does
GET /api/v1/payment-links/{id}One link, with its deliveries.
GET /api/v1/payment-linksYour links, newest first. Filters: status (PENDING, PARTIALLY_PAID, PAID, EXPIRED, CANCELLED), batch_id, page, limit (up to 100, default 25). Returns payment_links and pagination.
POST /api/v1/payment-links/{id}/cancelStops a link that is still waiting. What was already paid on it stays paid. A link that is no longer waiting answers 409 CONFLICT.
curl "https://api.sandbox.truepay.ompay.om/api/v1/payment-links?status=PAID&limit=10" \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET"

You only ever see your own links. A link that belongs to someone else answers 404, exactly like one that does not exist.

POST /api/v1/payment-links/{id}/deliver

FieldTypeRequiredNotes
channelstring✅EMAIL or SMS. The link must have a customer_email or customer_phone to send to.
{ "success": true, "data": { "channel": "EMAIL", "recipient": "aisha@example.com", "status": "SENT" } }

Only a link that can still be paid can be sent (409 CONFLICT otherwise).

10.6 Partial payments

With "allow_partial": true the customer may pay any part of the amount, as many times as they like, until the whole amount has been paid, the link expires, or you cancel it. status is PARTIALLY_PAID along the way and remaining_amount says what is left. Each instalment is its own payment in your Transactions.

POST /api/v1/payment-links/bulk creates up to 500 links in one call — either as a list:

{ "links": [
    { "amount": "5.000", "description": "Invoice 1", "customer_email": "a@example.com" },
    { "amount": "7.500", "reference": "INV-2", "customer_phone": "91234567" } ] }

or with the Excel template the portal uses (GET /api/v1/payment-links/bulk/template downloads it): { "file_base64": "&lt;the .xlsx file, base64&gt;", "filename": "march.xlsx" }. Send links or file_base64. Keep the request under 1 MB.

{ "success": true, "data": { "batch_id": "…", "total": 2, "created": 2, "errors": [] } }

A row that fails is listed in errors with its row number, for example { "row": 3, "error": "A positive amount is required." }, and the other rows are still created. List the links of a batch with GET /api/v1/payment-links?batch_id=…, and your batches with GET /api/v1/payment-links/bulk.

10.8 Defaults and limits

GET /api/v1/payment-links/config returns default_expiry_hours (the lifetime a link gets when you do not set one — OMPAY sets it), the longest you may ask for, the channels you can send on, and the bulk limit.

Creating links is limited to 120 a minute, and sending to 30 a minute, per merchant.

10.9 Errors

Every error has the shape in §5. The ones specific to payment links:

StatuserrorMeaning
400VALIDATION_ERRORSomething in the request is wrong; message says what (a bad amount, an email or phone that is not one, a reference together with allow_partial, both expires_at and expiry_hours, a past expires_at, a return_url that is not an address, a notify_via_… without the contact to send to).
402MERCHANT_CAPABILITY_DISABLEDPayment Links is not enabled for you.
404NOT_FOUNDNo such link (or it is not yours).
409CONFLICTThe link can no longer be cancelled or sent because it is no longer waiting for payment.
429RATE_LIMITEDToo many calls in a minute.
502PAYMENT_LINKS_UNAVAILABLEPayment links are unavailable right now. Nothing was created; retry (use an Idempotency-Key so a retry cannot make two links).

10.10 Testing in the sandbox

Create a link with the sandbox key, open its pay_url in a browser and pay with a test card (see Test cards). The link then reads PAID with a transaction_id. The API Sandbox page has a *Payment links* group with a ready-made request for every call above.


11. Settlement reports

Settlements are what OMPAY pays out to your bank account for the payments you took: the payments of a period, less any refunds and chargebacks, less OMPAY's fee and the VAT on it. These calls give you the same settlement reports as the OMPAY Merchant Portal — your settlements, the transactions in each, an Excel report, and the tax invoices — so your own systems can reconcile without anyone logging in.

EnvironmentEndpoint
Sandboxhttps://api.sandbox.truepay.ompay.om/api/v1/settlements
Productionhttps://api.truepay.ompay.om/api/v1/settlements

Authentication is the same two headers as everywhere else (§2). There is nothing to enable: every merchant can read its own settlements.

11.1 In three steps

1. List your settlements.

curl "https://api.sandbox.truepay.ompay.om/api/v1/settlements?status=PAID&limit=10" \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET"
{ "success": true,
  "data": {
    "settlements": [ {
      "id": "8d2f6c7e-1b4a-4c0e-9a55-3f2d8b7a6c10",
      "status": "PAID",
      "currency": "OMR",
      "period_start": "2026-10-01",
      "period_end": "2026-10-07",
      "transaction_count": 42,
      "gross_amount": "1250.000",
      "fee_amount": "18.750",
      "vat_amount": "0.938",
      "net_amount": "1230.312",
      "payout_reference": "BK-20261009-17",
      "paid_at": "2026-10-09T08:00:00.000Z",
      "destinations": [ { "account_last4": "6789", "beneficiary_name": "Acme LLC", "is_primary": true, "amount": "1230.312", "status": "PAID" } ]
    } ],
    "pagination": { "total": 14, "page": 1, "limit": 10, "pages": 2 } } }

2. Open one to see the transactions in it: GET /api/v1/settlements/{id}.

3. Download its Excel report: GET /api/v1/settlements/{id}/export — a .xlsx file.

curl -o Settlement.xlsx "https://api.sandbox.truepay.ompay.om/api/v1/settlements/$ID/export" \
  -H "OMPAY-API-Key: $KEY" -H "OMPAY-API-Secret: $SECRET"

11.2 List settlements

GET /api/v1/settlements — your settlements, newest first.

QueryMeaning
statusPENDING, PROCESSING, PAID, FAILED, CARRIED or CANCELLED.
date_from, date_toA day, YYYY-MM-DD. A settlement is included when its whole period falls inside the range, the same as the portal's filter.
page, limitPaging. limit up to 100, default 25.

Each settlement has:

FieldMeaning
idThe settlement's id. Use it in the other calls.
statusPENDING (being prepared or waiting to be paid), PROCESSING (sent to the bank), PAID, FAILED (the bank returned it; the money is back on your balance), CARRIED (nothing was paid out this time; the balance moves into your next settlement) or CANCELLED.
period_start, period_endThe days it covers (Oman time).
transaction_countTransactions in it.
gross_amountWhat you took in the period.
refund_amount, chargeback_amountTaken off, present only when there were any.
fee_amount, vat_amountOMPAY's fee and the VAT on it.
net_amountWhat is due to you for the period.
opening_balance, closing_balance, paid_out_amountYour running balance and what was actually sent to the bank. Present once the settlement has been processed.
payout_referenceThe bank's reference for the transfer, once paid.
paid_atWhen it was paid, or null.
destinationsWhich bank account(s) it went to: the account's last four digits, the amount and whether it landed (PAID, ON_ITS_WAY, NOT_SENT, RETURNED).

All amounts are strings in OMR with three decimals.

11.3 Summary

GET /api/v1/settlements/summary

{ "success": true,
  "data": { "currency": "OMR", "pending_amount": "412.500", "pending_count": 2,
            "paid_out_this_month": "1230.312", "bank_account_last4": "6789", "as_of": "2026-10-25T10:00:00.000Z" } }

pending_amount is what is owed to you in settlements that have not been paid out yet.

11.4 One settlement, with its transactions

GET /api/v1/settlements/{id} — the settlement (as in §11.2) and transactions:

FieldMeaning
transaction_idThe transaction (look it up with GET /api/v1/transactions/{transaction_id}, §3.6).
referenceYour reference for it.
rrnThe bank's retrieval reference number. Quote it to trace a payment.
card_schemeVisa, Mastercard, Maal, … or null.
typePAYMENT, REFUND, REVERSAL, HOLD, HOLD_RELEASE or CHARGEBACK.
amount, fee_amount, net_amountThe line's amount, its fee and what is left.
transaction_dateWhen it happened.

11.5 Files

These return a file, not JSON. Save the response body.

CallFile
GET /api/v1/settlements/{id}/exportThe settlement's Excel report: a *Summary* sheet (gross to net, period, payout, running balance) and a *Transactions* sheet (one row per transaction, with totals).
GET /api/v1/settlements/export?date_from=…&amp;date_to=…An Excel report of the settlements in a period: one row per settlement, with totals. status narrows it, as in §11.2.
GET /api/v1/settlements/{id}/tax-invoiceOMPAY's tax invoice for the fees on that settlement (PDF).
GET /api/v1/tax-invoicesThe monthly tax invoices OMPAY issued to you (JSON), newest first: id, invoice_number, year, month, the period, the counts, gross_amount, fee_amount, vat_amount, payable_amount, issued_at.
GET /api/v1/tax-invoices/{id}/pdfOne monthly tax invoice (PDF).

Monthly tax invoices are issued to the main merchant account, with each sub-merchant's activity itemised inside. A sub-merchant's own key has none: its list is empty and the PDF call answers 403 NOT_AVAILABLE.

11.6 What you see

A key sees the settlements of the merchant it belongs to; if you have sub-merchants, use each one's own key (or the portal, which shows them together).

11.7 Errors

Every error has the shape in §5. The ones specific to settlement reports:

StatuserrorMeaning
400VALIDATION_ERRORA filter is not valid: a status that is not one of the six, a date that is not YYYY-MM-DD, or date_from after date_to.
403NOT_AVAILABLEA monthly tax invoice was asked for by a sub-merchant.
404NOT_FOUNDNo such settlement or invoice (or it is not yours).
429RATE_LIMITEDToo many calls in a minute (120 for data, 20 for files).
502SETTLEMENT_REPORTS_UNAVAILABLESettlement reports are unavailable right now. Nothing is lost; try again.

11.8 Testing in the sandbox

In the API Sandbox page, the *Manage* group has a request for every call above. The file calls show a Download button beside the response. A new sandbox merchant has no settlements until a settlement run has happened for its payments; the list is then empty ("settlements": []) and the file calls need a settlement id from a run.