Posted on

HolestPay FrontCore Guide for Lovable and Similar AI Site Builder Systems

Tell Lovable (or similar system…) that you want to integrate HolestPay payments/fiscal/shipping and give it this MD markup: https://apps.holest.com/holest-pay/Lovable_or_similar_ai_system.MD.txt
Lovable will know then what to ask you and what to instruct you to do…

Make sure to SET UP FIRST ALL methods you plan to use on https://sandbox.pay.holest.com/ (payment | fiscal | shipping) and test them using “pay-by-link” (entirely off-site). If everything is configured properly, the integration will go smoothly with just a few queries.

HolestPay FrontCore Guide for Lovable and Similar AI Site Builder Systems – MD file…


# HolestPay FrontCore Integration — Guide for Lovable / AI Code Generators

> **Full technical reference:** https://apps.holest.com/holest-pay/AI_INTEGRATION_GUIDE_FRONTCORE.md.txt  
> **Working sample (sandbox):** https://apps.holest.com/holest-pay/test/sandbox-164-hpay-frontcore-sample.html  
> (View page source and inspect `` tags for real implementation examples.)
>
> **Sample data files — read these to understand real data structures:**
> - `pos_as_read.json` — https://apps.holest.com/holest-pay/pos_as_read.json  
>   Full `client.POS` (same as HPay.POS) object as returned by `HPayInit()`. Contains `payment`, `shipping`, and `fiscal` method arrays with all their properties (`Uid`, `HPaySiteMethodId`, `Name`, `Hidden`, `SubsciptionsType`, `POps`, `PayInputUrl`, `Use IFRAME`, etc.). **Read this to know what payment/shipping methods look like before building any selector UI.**
> - `response_sample.json` — https://apps.holest.com/holest-pay/response_sample.json  
>   Full `hpay_response` object as received in `onHPayResult` event and as POST-back `hpay_forwarded_payment_response`. Contains `payment_status`, `status`, `transaction_uid`, `transaction_user_info`, `vault_token_uid`, `vault_card_brand`, `vault_card_umask`, `vault_exp`, `payment_html`, `fiscal_html`, `integr_html`, `shipping_html`, and all other result fields. **Read this to know what data is available when displaying the thank-you / order confirmation page.**

---

## ?? FIRST ACTION — Ask the User for Their FrontCore Script URL

**Before writing any code, ask the user:**

> *"Please give me your HolestPay FrontCore script URL. It looks like this:*
> ``
> *Do you have it?"*

**If the user says they don't have it, or doesn't know what it is, explain:**

> *"This is a unique `
```

The UID segment in the URL (`07f689c5-...`) is the Merchant Site UID — it is embedded in the URL itself.

**If the user confirms they need backend implementation** (admin actions, backend charges — see pre-build questions above), they must also provide:
- **Merchant Site UID** — visible in HPay panel ? Site Settings (also in the FrontCore script URL)
- **Merchant Site Secret Key** — visible in HPay panel ? Site Settings (keep this server-side only, never expose on frontend)

### ?? Configuration values must be easily changeable by the user

Store all three of these as **user-editable configuration** (environment variables, a settings screen, or a clearly marked config file) — **never hardcode them**:

| Value | Where used | Why it changes |
|---|---|---|
| FrontCore script `src` URL | Frontend, global `
```

When the HPay script is loaded, it also exposes these globals on `window`:
- `window.presentHPayPayForm` — function
- `window.HPayIsSandbox` — environment flag variable

Add a startup check to catch misconfiguration early:

```javascript
// After page load — warn developer if FrontCore failed to initialize
setTimeout(function() {
  if (typeof HolestPayCheckout === 'undefined') {
    console.error(
      'HolestPay FrontCore script did not load. ' +
      'Check that the current origin is listed in "Frontend Script-Core Origins" in the HPay panel.'
    );
  }
}, 5000);
```

---

### Step 2 — Initialize HPay and Load Payment/Shipping Methods

Call `HPayInit()` once when the checkout page loads (or when the user reaches it). It returns a Promise.

- `merchant_site_uid` and environment (`sandbox`/`production`) are **not** parameters — they come from the script URL automatically.
- Language is optional — if omitted, HPay uses the `` attribute or the language set in HPay panel POS settings.

```javascript
// Save this after HPayInit so it can be used in charge_request and admin ops later
let hpayMerchantSiteUid = null;

HPayInit(/* language optional, e.g. 'en' */).then(async client => {
  hpayMerchantSiteUid = client.MerchantsiteUid;

  // Filter available methods by buyer's country (recommended)
  const country = 'RS'; // use actual buyer country, ISO 3166-1 alpha-2
  let availablePayment = [], availableShipping = [];
  try { availablePayment  = await HPay.availablePaymentMethods(country, orderAmount, orderCurrency); } catch(e) {}
  try { availableShipping = await HPay.availableShippingMethods(country, orderAmount, orderCurrency); } catch(e) {}

  // Populate payment method selector
  client.POS.payment.forEach(pm => {
    if (!pm.Hidden && (!availablePayment.length || availablePayment.find(m => m.Uid == pm.Uid))) {
      // Add pm to your UI selector
      // pm.HPaySiteMethodId ? use as pay_request.payment_method
      // pm.Name             ? display name (use this — do NOT invent a name)
      // pm.SubsciptionsType ? contains "cof" or "mit" ? card saving is supported for this method
      // pm.POps             ? e.g. "charge,refund" ? backend operations available
      // pm.PayInputUrl      ? if set, this method supports docking (embedded payment input)
      // pm['Use IFRAME']    ? if false, method uses redirect flow (bank page POST redirect)
    }
  });

  // Populate shipping method selector (if POS has shipping configured)
  if (client.POS.shipping) {
    client.POS.shipping.forEach(sm => {
      if (!sm.Hidden && (!availableShipping.length || availableShipping.find(m => m.Uid == sm.Uid))) {
        // sm.HPaySiteMethodId ? use as pay_request.shipping_method
        // sm.Name             ? display name
      }
    });
  }
});
```

> **Make sure existing payment methods in your UI have a name and description.** Use `pm.Name` from the POS config — do not hardcode or invent names.

---

### Step 3 — Build the `pay_request` and Initiate Payment

```javascript
const pay_request = {
  // NOTE: merchant_site_uid is NOT included here for FrontCore — the script handles auth automatically
  hpaylang: 'en',                                // optional
  order_uid: 'ORDER-20260425-001',               // required — unique per order
  order_name: '#Order 204',                      // optional
  order_amount: '15000.00',                         // required — string, in smallest currency unit or decimal
  order_currency: 'RSD',                         // required — ISO 4217
  payment_method: '179',                         // required — pm.HPaySiteMethodId from Step 2
  shipping_method: '45',                         // optional — sm.HPaySiteMethodId
  order_user_url: 'https://yoursite.com/thanks', // recommended — redirect/thank-you page URL
  notify_url: 'https://yoursite.com/webhook',    // optional — server-to-server webhook (must be publicly reachable)
  cof: 'optional',                               // optional — 'optional'|'required'|'none' — enables card saving
  vault_token_uid: '',                           // optional — saved token UUID, or 1|true|new to force new token
  order_billing: {                               // optional but recommended
    email: 'customer@example.com',
    first_name: 'TEST',
    last_name: 'TEST',
    phone: '+38111111111',
    is_company: 0,
    company: '',                                   // company legal name
    company_tax_id: '',                            // company tax ID in merchant's country
    company_reg_id: '',                            // company registration ID in merchant's country
    address: 'TEST',                                // street name
    address2: '',                                   // recommended for street number / address addition
    city: 'Beograd',
    country: 'RS',
    state: 'Beograd',
    postcode: '11000',
    lang: 'sr_RS'                                // language from merchant platform/system
  },
  order_shipping: {                              // optional
    shippable: false,
    is_cod: 1,                                      // set to 1 when COD logic is used/allowed
    first_name: '',
    last_name: '',
    phone: '',
    company: '',
    address: '',                                    // street name
    address2: '',                                   // recommended for street number / address addition
    city: '',
    country: '',
    state: '',
    postcode: ''
  },
  order_items: [                                  // strongly recommended for reconciliation
    {
      posuid: 114,                                           // merchant's own internal item ID (string or number)
      type: 'product',
      name: 'Sample product name',
      sku: '000550',
      qty: 1,
      price: 4695.99,
      subtotal: 4695.99,
      tax_label: '',
      tax_amount: 0,
      length: '',                                          // always in centimeters (cm)
      width: '',                                           // always in centimeters (cm)
      height: '',                                          // always in centimeters (cm)
      weight: '',                                          // always in grams (g)
      split_pay_uid: '',
      virtual: true,
      tax_percent: 0
    }
  ]
};

// Remove empty fields before sending
Object.keys(pay_request).forEach(k => { if (pay_request[k] === '') delete pay_request[k]; });

// Initiate payment — shows modal or triggers redirect depending on payment method
HPay.presentHPayPayForm(pay_request);
```

**Important — `order_items` field naming must match HolestPay template keys:**

- Use `name`, not `title`
- Use `posuid`, not `variantId`
- Use `qty`, not `quantity`
- `subtotal` is mandatory for each item line
- `posuid` can be any identifier from the merchant's system (SKU/variant/product/internal DB ID)

If source platform fields are Shopify-style, map them explicitly before sending:
- `variantId -> posuid`
- `title -> name`
- `quantity -> qty`
- top-level `items -> order_items`

The same order payload structure is used across all three markups/docs (Lovable prompt, FrontCore guide, Standard guide).
Address convention recommendation: use `address` for street name and `address2` for street number/additional address details.

---

### Step 4 — Docking (Embedded Payment Input)

Some payment methods support an embedded payment input directly in the page (e.g. card form inline). Check `pm.PayInputUrl` — if it is set, docking is supported for that method.

Call `HPay.setPaymentMethodDock()` **when the user selects a payment method or when any checkout field changes** (amount, currency, etc.).

```html

``` ```css #paymentMethodDock { background: #ffffff9e; } ``` ```javascript function updateDock() { const selectedPm = /* pm object for selected payment method */; if (selectedPm && selectedPm.PayInputUrl) { HPay.setPaymentMethodDock( pay_request.payment_method, { order_amount: pay_request.order_amount, order_currency: pay_request.order_currency, monthly_installments: null, vault_token_uid: pay_request.vault_token_uid || null, hpaylang: pay_request.hpaylang, cof: pay_request.cof }, document.getElementById('paymentMethodDock') ); } else { document.getElementById('paymentMethodDock').innerHTML = ''; } } ``` --- ### Step 5 — Handle Results #### A — Event handler (iframe / modal methods) Most payment methods return the result via this event, fired on the current page: ```javascript document.addEventListener('onHPayResult', function(e) { const r = e.hpay_response; if (!r) return; if (r.error && r.error.code) { // Payment error — retry HPay.presentHPayPayForm(pay_request); return; } if (/PAID|RESERVED|SUCCESS|PAYING|OBLIGATED|AWAITING/i.test(r.payment_status)) { // Payment completed or pending payment instructions (AWAITING/OBLIGATED are NOT failed) // Clear cart for PAID/RESERVED and also for PAYING/AWAITING/OBLIGATED. // Display HTML receipts in dedicated containers on your thank-you page: // r.payment_html ? payment receipt HTML // r.fiscal_html ? fiscal receipt HTML (if fiscal module active) // r.integr_html ? integration module HTML // r.shipping_html ? shipping label/receipt HTML (if shipping module active) // r.transaction_user_info ? object with card brand, masked number, etc. if (r.vault_token_uid) {//only if card save is on for some reason subscription or simply token save for faster checkout // IMPORTANT: save this to your database linked to the user account — do NOT only store in browser const saveCardData = { vault_token_uid: r.vault_token_uid, //e.g. hJ0khUi67856765rtyrytrytry 12 up to 128 characters vault_card_brand: r.vault_card_brand, // e.g. "MASTERCARD" vault_card_umask: r.vault_card_umask, // e.g. "544358******4639" vault_exp: r.vault_exp, // e.g. "12/26" vault_scope: r.vault_scope, // e.g. can be Terminal ID fro payment method config or soemthinglike that vault_onlyforuser: r.vault_onlyforuser, //subscription falsy | fast checkout 0 pay_method_uid: r.pay_method_uid //Uid of payment method from HPay.POS.payment[N].Uid }; // POST saveCardData to your backend } // Redirect to thank-you page if needed: // window.location.href = r.order_user_url; } // r.status ? order status (always present) }); document.addEventListener('onHPayPanelClose', function(e) { const r = e.hpay_response; // null if user closed without completing const reason = String((r && r.reason) || '').toLowerCase(); // If pay button was locked during payment start, unlock it on close reasons below. if (/^(user|timeout|cancel|error)$/.test(reason)) { const payBtn = document.getElementById('do-pay'); if (payBtn) payBtn.disabled = false; } if (r && r.reason === '' && /* user wants retry */ false) { setTimeout(() => HPay.presentHPayPayForm(pay_request), 300); } }); ``` #### B — POST-back redirect (redirect methods) Some payment methods (e.g. certain bank integrations) require a **full page redirect to the bank**. `HPay.presentHPayPayForm()` handles this automatically with a POST redirect. After the bank interaction, the user is returned to `order_user_url` via a POST-back. On `order_user_url` (your thank-you/return page), read the result from the POST body: ```javascript // Server-side (Node.js example) // The POST body contains: hpay_forwarded_payment_response = JSON string const result = JSON.parse(req.body.hpay_forwarded_payment_response); // result is the same structure as e.hpay_response from onHPayResult ``` > Implement **both** the event handler (Step 5A) and the POST-back handler (Step 5B). Which one fires depends on the payment method the buyer chooses — you cannot predict it at build time. #### C — Webhook topics, panel URL, and `notify_url` override If server-to-server notifications are needed, configure panel setting: - `I(P|S|F|I)N - Link za instant notifikacije - plaćanje/isporuka/fiskal/integracije` - `I(P|S|F|I)N - Instant payment/shipping/fiscal/integation notification url` If request payload includes `notify_url`, it overrides panel-level `I(P|S|F|I)N` URL for that request. HPay appends query string parameter `topic` and sends POST JSON. `topic` can be: - `payresult` — same shape as `onHPayResult` / `https://apps.holest.com/holest-pay/response_sample.json` - `orderupdate` — root includes `order_uid`, `status`, `vhash`; payload fields are the same as `payresult`; and has `order` property with HolestPay order object in the same format as `ord` from `HPay.getOrder(order_uid).then(ord => {...})` (`https://apps.holest.com/holest-pay/hpay_order_sample.json`) (`"id"` is ID from HPay system); validate `vhash` the same way as `payresult` - `posconfig-updated` — contains POS object (`HPay.POS` format plus non-public fields), `environment`, and `checkstr = md5(${merchant_site_uid}${secret_token})` Important payload mapping note: - Request and response payloads are similar in shape; response usually contains request fields plus normalization and additional result fields. - Hash field names are different by direction: request -> `verificationhash`, response -> `vhash`. - `order` payload is a different model (same as `HPay.getOrder(...)` `ord` object), so do not map it as request/response. - Example key differences: - request/response `order_uid`, `order_amount`, `order_currency` -> order object `Uid`, `Amount`, `Currency` - request/response `order_items`, `order_billing`, `order_shipping` -> order object `Data.items`, `Data.billing`, `Data.shipping` - fiscal method specific data -> `order.FiscalData[fiscal_method_uid] = {...}` (module-specific payload) - integration method data -> `order.Data[integr_method_uid] = {...}` (may exist or may be empty/missing) - shipping method data -> `order.ShippingData[real_or_temp_shipping_code] = { method_uid: shipping_method_uid, ... }` The same result may arrive from browser callback and webhook; use `vhash` (plus order/transaction IDs) to prevent duplicate processing. #### D — Redirect POST-back parse robustness For redirect methods, HPay can return result via: ```html
``` If `JSON.parse` fails on first try, normalize escaped payload and retry: ```javascript function parseForwardedHPayResponse(raw) { if (!raw) return null; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch (_) {} const unescaped = String(raw).replace(/\\\\\"/g, '"').replace(/\\\\\\\\/g, '\\'); try { return JSON.parse(unescaped); } catch (_) {} return null; } ``` --- ### Step 6 — Display HTML Receipts on Thank-You Page On the thank-you page, always render data in this order: 1) `transaction_user_info` block first 2) `payment_html`, `fiscal_html`, `shipping_html`, `integr_html` blocks For bank production approval, thank-you page must also show complete order summary for all outcomes (success, failed, awaiting payment): - buyer/customer identity data - billing and email data - shipping data - order number - ordered products list with quantity, unit price, and line totals - shipping cost - payment method name and shipping method name - grand total amount If any field is missing in response, keep the block visible and show a placeholder message. Create containers for transaction details and each receipt type: ```html
``` ```javascript function renderTransactionUserInfo(info, keyMap) { const host = document.getElementById('hpay-transaction-user-info'); if (!host) return; host.innerHTML = ''; if (!info || typeof info !== 'object' || !Object.keys(info).length) { host.innerHTML = '

Transaction details are not available.

'; return; } const dl = document.createElement('dl'); Object.entries(info).forEach(([k, v]) => { const dt = document.createElement('dt'); const dd = document.createElement('dd'); dt.textContent = keyMap[k] || k; // keys can be translated dd.textContent = String(v ?? ''); // values must stay original dl.appendChild(dt); dl.appendChild(dd); }); host.appendChild(dl); } renderTransactionUserInfo(r.transaction_user_info, { 'Order UID': 'Order UID', 'Payment Status': 'Payment Status', 'Transaction Time': 'Transaction Time', 'Amount in order currency': 'Amount in order currency', 'Amount in payment currency': 'Amount in payment currency', 'Bank Account': 'Bank Account', 'REF MOD97 PNB': 'REF MOD97 PNB', 'Purphose': 'Purpose' }); const FALLBACK_HTML = '

Not available for this payment.

'; document.getElementById('hpay-receipt-payment').innerHTML = r.payment_html || FALLBACK_HTML; document.getElementById('hpay-receipt-fiscal').innerHTML = r.fiscal_html || FALLBACK_HTML; document.getElementById('hpay-receipt-shipping').innerHTML = r.shipping_html || FALLBACK_HTML; document.getElementById('hpay-receipt-integr').innerHTML = r.integr_html || FALLBACK_HTML; ``` Example `transaction_user_info` payload: ```json { "transaction_user_info": { "Order UID": "NIPI-1777290504591-8X6YD2", "Payment Status": "AWAITING", "Transaction Time": "2026-04-27 13:48:27.847Z", "Amount in order currency": "1360.00 RSD", "Amount in payment currency": "1360.00 RSD", "Bank Account": "160-6000002552312-02", "REF MOD97 PNB": "(97) 2726042713", "Purphose": "Order npinipi17772905045918x6yd2" } } ``` Rule: keys may be translated for UI labels, but values should be displayed exactly as received. If any section is missing, render a visible placeholder instead of hiding the section. For `AWAITING` and `OBLIGATED`, always render `payment_html` on the thank-you page. These are not failed statuses; they are commonly used for account/invoice payment instructions (bank transfer details). Cart handling rule: clear cart for `PAID`, `RESERVED`, `PAYING`, `AWAITING`, and `OBLIGATED` (same cart behavior for all these statuses). --- ### Step 7 — Verify Response `vhash` on Server (Node.js) Important naming: - request to HPay (`pay_request` / `charge_request`) -> field `verificationhash` -> generate with `generatePOSRequestSignature(...)` - response from HPay (browser callback / webhook) -> field `vhash` -> validate with `verifyHPayResponse(...)` Always verify response `vhash` server-side before marking order as paid/fulfilled. ```javascript const crypto = require('crypto'); const md5 = require('md5'); function generatePOSRequestSignature(merchant_site_uid, secretKey, payload) { const amount = Number(payload.order_amount ?? 0).toFixed(8); const src = String(payload.transaction_uid ?? '').trim() + '|' + String(payload.status ?? '').trim() + '|' + String(payload.order_uid ?? '').trim() + '|' + amount + '|' + String(payload.order_currency ?? '').trim() + '|' + String(payload.vault_token_uid ?? '').trim() + '|' + String(payload.subscription_uid ?? '').trim() + String(payload.rand ?? '').trim(); const srcMd5 = md5(src + merchant_site_uid); return crypto.createHash('sha512').update(srcMd5 + secretKey).digest('hex').toLowerCase(); } function verifyHPayResponse(result, merchant_site_uid, secretKey) { if (!result || !result.vhash) return false; if (!result.order_uid || !String(result.order_uid).trim()) return false; const expected = generatePOSRequestSignature(merchant_site_uid, secretKey, { transaction_uid: result.transaction_uid ?? '', status: result.status ?? '', order_uid: result.order_uid, order_amount: result.order_amount ?? 0, order_currency: result.order_currency ?? '', vault_token_uid: result.vault_token_uid ?? '', subscription_uid: result.subscription_uid ?? '', rand: result.rand ?? '' }); return expected === String(result.vhash).toLowerCase(); } ``` --- ## Optional — Advanced: Backend Charges (Server-Side, Subscriptions / MIT) > **Skip this section** unless the user explicitly asked for subscriptions or automatic recurring charges. This is used by a small fraction of integrations. The standard checkout flow (Steps 1–6—6) is completely independent of this. ### What it is A backend charge lets your **server** silently charge a customer's saved card without any buyer interaction — no payment form, no redirect. This is used for: - Recurring subscription billing (charge every month automatically) - MIT (Merchant Initiated Transactions) — charging after service delivery ### Prerequisites for backend charge - A payment method with `pm.SubsciptionsType` containing `"cof"` or `"mit"` must be active on the POS - `pm.POps` must contain `"charge"` for that method - A `vault_token_uid` saved from a previous successful checkout (from `r.vault_token_uid` in `onHPayResult`) must exist in your database for that customer - **Merchant Site UID** and **Merchant Site Secret Key** (server-side only) ### How the token gets saved During a normal checkout (Step 5), if card saving was enabled (`cof: 'optional'|'required'`), the `onHPayResult` response will contain a `vault_token_uid`. You **must save this to your database** linked to the user's account. The backend charge uses this token later. ### Backend charge request (Node.js) ```javascript // SERVER-SIDE ONLY — never run this on the frontend const crypto = require('crypto'); const md5 = require('md5'); // Signature function — required to authenticate the charge request function generatePOSRequestSignature(merchant_site_uid, secretKey, request) { const amt = parseFloat(request.order_amount || 0).toFixed(8); let cstr = [request.transaction_uid, request.status, request.order_uid, amt, request.order_currency, request.vault_token_uid, request.subscription_uid].map(v => String(v || '').trim()).join('|'); cstr += String(request.rand || '').trim(); return crypto.createHash('sha512') .update(md5(cstr + merchant_site_uid) + secretKey) .digest('hex').toLowerCase(); } // charge_request has same structure as pay_request but: // - merchant_site_uid IS required (backend operation) // - vault_token_uid IS required (the saved token from your DB) // - order_user_url is NOT needed (no buyer to redirect) // - cof is NOT needed const charge_request = { merchant_site_uid: MERCHANT_SITE_UID, // from config — never hardcode order_uid: 'ORDER-20260425-001', // unique per charge attempt order_amount: '15000', order_currency: 'RSD', payment_method: '179', // pm.HPaySiteMethodId vault_token_uid: 'saved-token-from-db', // from your database for this customer }; charge_request.verificationhash = generatePOSRequestSignature( charge_request.merchant_site_uid, MERCHANT_SITE_SECRET, charge_request ); const BASE_URL = 'https://sandbox.pay.holest.com'; // or https://pay.holest.com for production const response = await fetch(BASE_URL + '/clientpay/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charge_request) }); const result = await response.json(); // result has same structure as e.hpay_response from onHPayResult // check result.payment_status for PAID|RESERVED|SUCCESS|PAYING|OBLIGATED|AWAITING if (/PAID|PAYING|RESERVED|OBLIGATED|AWAITING/i.test(result.payment_status)) { // charge succeeded } ``` --- ## Optional: Shipping Address Autocomplete (AdaptCheckout) If the user uses HolestPay shipping modules, some methods provide address autocomplete/suggestion UI. Call `adaptCurrentShipping()` whenever the selected shipping method changes. Pass `null` to destroy the adapter when no shipping method is selected. The selector strings map your existing checkout input fields to HolestPay's address model — **update them to match your actual input selectors**. ```javascript let adapted_checkout_destroy = null; let adapted_shipping_method_uid = null; function adaptCurrentShipping(shipping_method_uid) { try { if (shipping_method_uid) { if (shipping_method_uid === adapted_shipping_method_uid) return; const smethod = HPay.POS.shipping.find(s => s.Uid == shipping_method_uid); if (smethod && smethod.AdaptCheckout) { adapted_checkout_destroy = smethod.AdaptCheckout({ billing: { address: "#addressInput[name='address1']", // update selectors to match your inputs address_num: "#addressLine2Input[name='address2']", postcode: "#postCodeInput[name='postalCode']", city: "#cityInput[name='city']", municipality: "#provinceInput[name='stateOrProvince']", phone: "#phoneInput[name='phone']", country: "#countryCodeInput[name='countryCode']", is_company: "#companyInput[name='company']", company: "#companyInput[name='company']", company_tax_id: "", company_reg_id: "" }, shipping: { address: "#addressInput[name='shippingAddress.address1']", address_num: "#addressLine2Input[name='shippingAddress.address2']", postcode: "#postCodeInput[name='shippingAddress.postalCode']", city: "#cityInput[name='shippingAddress.city']", municipality: "#provinceInput[name='shippingAddress.stateOrProvince']", phone: "#phoneInput[name='shippingAddress.phone']", country: "#countryCodeInput[name='shippingAddress.countryCode']", is_company: "#companyInput[name='shippingAddress.company']", company: "#companyInput[name='shippingAddress.company']", company_tax_id: "", company_reg_id: "" } }) || null; adapted_shipping_method_uid = shipping_method_uid; } } else { if (adapted_checkout_destroy) { adapted_checkout_destroy(); adapted_checkout_destroy = null; adapted_shipping_method_uid = null; } } } catch(ex) { console.error(ex); } } ``` --- ## No Backend Needed for Basic Checkout If the site runs on Shopify, BigCommerce, or any platform where you cannot run custom server code, the FrontCore flow (Steps 1–6—6) is fully self-contained — no backend is required for standard checkout. Backend code is only needed if you implement: - **Backend charges** (subscription / MIT auto-charge) - **Admin operations** (refunds, captures, etc.) - **Webhook verification** (verify response `vhash` from `notify_url` payload) For webhooks and backend operations you also need the **Merchant Site Secret Key** (from HPay panel ? Site Settings). --- ## Checklist - [ ] FrontCore ` ``` In this URL, the `07f689c5-5bc8-44b6-a563-8facc6870fab` segment is the **Merchant Site UID**. When this FrontCore script loads on an allowed origin, `HolestPayCheckout` and FrontCore signing (`hpay_frontend_script_core_sign`) become available. After loading, the global `HolestPayCheckout` object and `HPayInit()` become available. When the HPay script is loaded, it also exposes these globals on `window`: - `window.presentHPayPayForm` — function - `window.HPayIsSandbox` — environment flag variable Add a check to warn about misconfiguration (add after page loads): ```javascript setTimeout(function() { if (typeof HolestPayCheckout === 'undefined') { alert( 'HOLESTPAY FRONT-CORE SCRIPT IS NOT LOADED. ' + 'YOU PROBABLY FORGOT TO ADD CURRENT ORIGIN TO ' + '"Frontend Script-Core Origins" PARAMETER UNDER SITE/POS SETTINGS!' ); } }, 5000); ``` --- ## Step 1 — Initialize HPay and Fetch POS Configuration `HPayInit()` returns a Promise resolving to `client` (= global `HPay`). ```javascript HPayInit( language // string — e.g. "en", "rs", "de" — also optional. // If omitted, HPay will use the HTML attribute, // or fall back to the fixed language configured in HPay panel POS settings. // NOTE: merchant_site_uid and environment are NOT required for FrontCore — // they are determined automatically by the POS-specific script URL. ).then(async client => { // client.MerchantsiteUid — the Merchant Site UID read from the loaded POS config const merchantSiteUid = client.MerchantsiteUid; // save for use in charge_request / admin ops // client.POS.payment — array of payment method objects // client.POS.shipping — array of shipping method objects // client.POS.fiscal — array of fiscal method objects // Filter by buyer country (optional but recommended): let availablePayment = []; let availableShipping = []; const country = 'RS'; try { availablePayment = await HPay.availablePaymentMethods(country, orderAmount, orderCurrency); } catch(e) { console.error(e); } try { availableShipping = await HPay.availableShippingMethods(country, orderAmount, orderCurrency); } catch(e) { console.error(e); } // Build payment method selector from client.POS.payment: client.POS.payment.forEach(pm => { if (!pm.Hidden && (!availablePayment.length || availablePayment.find(m => m.Uid == pm.Uid))) { // pm.HPaySiteMethodId — use as pay_request.payment_method value // pm.Name — display name // pm.SubsciptionsType — contains "cof" or "mit" if card saving is supported // pm.POps — available backend operations e.g. "charge,refund" // pm.PayInputUrl — set means docking (embedded form) is supported // pm['Use IFRAME'] — if false, method uses redirect flow } }); // Build shipping method selector from client.POS.shipping: if (client.POS.shipping) { client.POS.shipping.forEach(sm => { if (!sm.Hidden && (!availableShipping.length || availableShipping.find(m => m.Uid == sm.Uid))) { // sm.HPaySiteMethodId — use as pay_request.shipping_method value } }); } }); ``` --- ## Step 2 — Build the `pay_request` Object ```javascript const pay_request = { // NOTE: merchant_site_uid is NOT included in pay_request for FrontCore. // The POS-specific script authenticates the request automatically. hpaylang: "en", // optional UI language order_uid: "20260315-621417", // required unique order ID order_name: "#Order 204", // optional order label order_amount: "15000", // required order_currency: "RSD", // required ISO 4217 payment_method: "179", // required pm.HPaySiteMethodId shipping_method: "45", // optional sm.HPaySiteMethodId order_user_url: "https://yoursite.com/thanks", // optional redirect/thank-you URL notify_url: "https://yoursite.com/webhook", // optional public webhook URL order_data: { // optional custom payload -> stored as order.Data customer_segment: "B2C", source: "checkout-web" }, cof: "optional", // optional: optional|required|none vault_token_uid: "saved-token-uuid", // optional: saved token, or 1|true|new // Optional billing object (template fields): order_billing: { email: "customer@example.com", first_name: "TEST", last_name: "TEST", phone: "+38111111111", is_company: 0, company: "", // company legal name company_tax_id: "", // company tax ID in merchant's country company_reg_id: "", // company registration ID in merchant's country address: "TEST", // street name address2: "", // recommended for street number / address addition city: "Beograd", country: "RS", state: "Beograd", postcode: "11000", lang: "sr_RS" // language from merchant platform/system }, // Optional shipping object (template fields): order_shipping: { shippable: false, is_cod: 1, // set to 1 when COD logic is used/allowed first_name: "", last_name: "", phone: "", company: "", address: "", // street name address2: "", // recommended for street number / address addition city: "", country: "", state: "", postcode: "" }, // Optional items array (template fields): order_items: [ { posuid: 114, // merchant's own internal item ID (string or number) type: "product", name: "Sample product name", sku: "000550", qty: 1, price: 4695.99, subtotal: 4695.99, refunded: 0, refunded_qty: 0, tax_label: "", tax_amount: 0, length: "", width: "", height: "", weight: "", split_pay_uid: "", virtual: true, tax_percent: 0 } ] }; // NOTE: For initial FrontCore checkout, do not send signature hash. // Remove empty fields: Object.keys(pay_request).forEach(k => { if (pay_request[k] === '') delete pay_request[k]; }); ``` **Important — `order_items` naming must use HolestPay keys (do not pass raw platform keys):** - Use `name`, not `title` - Use `posuid`, not `variantId` - Use `qty`, not `quantity` - `subtotal` is mandatory for each item line - `posuid` can be any identifier from the merchant's system (SKU/variant/product/internal DB ID) Common Shopify mapping before send: - `variantId -> posuid` - `title -> name` - `quantity -> qty` - top-level `items -> order_items` The same order payload structure is used across all three markups/docs (Lovable prompt, FrontCore guide, Standard guide). Address convention recommendation: use `address` for street name and `address2` for street number/additional address details. ### Full Template Field Catalog (Standalone) - `Top-level pay_request` (FrontCore — `merchant_site_uid` is **not** included here): - `hpaylang`, `order_uid`, `order_name`, `order_amount`, `order_currency` - `payment_method`, `shipping_method`, `order_user_url`, `notify_url` - `order_data`, `cof`, `vault_token_uid` - `order_billing`: - `email`, `first_name`, `last_name`, `phone` - `is_company`, `company` (legal name), `company_tax_id` (tax ID), `company_reg_id` (registration ID) - `address`, `address2`, `city`, `country`, `state`, `postcode`, `lang` - `order_shipping`: - `shippable`, `is_cod` - `first_name`, `last_name`, `phone`, `company` - `address`, `address2`, `city`, `country`, `state`, `postcode` - `dispenser`, `dispenser_desc`, `dispenser_method_id` (locker/paket-shop flows) - `order_items[]`: - `posuid`, `type`, `name`, `sku`, `qty`, `price`, `subtotal` - Required minimum per line for reliable processing: `posuid`, `name`, `qty`, `subtotal` - `refunded`, `refunded_qty`, `tax_label`, `tax_amount` - `length`, `width`, `height`, `weight`, `split_pay_uid`, `virtual`, `warehouse` - `setPaymentMethodDock(...)` data: - `order_amount`, `order_currency`, `monthly_installments`, `vault_token_uid`, `hpaylang`, `cof` - Signature input/result fields used for backend charge and verification: - `transaction_uid`, `status`, `order_uid`, `order_amount`, `order_currency`, `vault_token_uid`, `subscription_uid`, `rand` - Compare generated signature with response `vhash` (not request `verificationhash`). - `Extensibility`: - `order_data` may contain any custom key/value pairs; it is persisted to `order.Data` in HPay order. - `order_billing` and `order_shipping` may include additional custom fields besides the listed ones. - Total serialized order payload should stay below **64 KB**. --- ## Step 3 — Present the Payment Form ```javascript // Option A: Modal HPay.presentHPayPayForm(pay_request); // Option B: Docked (embedded) — only if pm.PayInputUrl is set const dockElement = document.getElementById('paymentMethodDock'); HPay.setPaymentMethodDock( pay_request.payment_method, { order_amount: pay_request.order_amount, order_currency: pay_request.order_currency, monthly_installments: null, vault_token_uid: pay_request.vault_token_uid || null, hpaylang: pay_request.hpaylang, cof: pay_request.cof }, dockElement ); // Trigger payment on Pay button click: HPay.presentHPayPayForm(pay_request); ``` Dock container CSS: ```css #paymentMethodDock { background: #ffffff9e; } ``` --- ## Step 4 — Handle the Result Events Use these event handlers on your FrontCore page: ```javascript document.addEventListener('onHPayResult', function(e) { const r = e.hpay_response; if (!r) return; if (r.error && r.error.code) { HPay.presentHPayPayForm(pay_request); // retry return; } if (/PAID|RESERVED|SUCCESS|PAYING|OBLIGATED|AWAITING/i.test(r.payment_status)) { // r.payment_html, r.fiscal_html, r.integr_html, r.shipping_html — HTML receipts // r.transaction_user_info — object with card/transaction details // r.order_user_url — redirect to thank-you page if needed // IMPORTANT: AWAITING/OBLIGATED are NOT failed; show r.payment_html on thank-you page // IMPORTANT: clear cart for PAID/RESERVED and also for PAYING/AWAITING/OBLIGATED. // window.location.href = r.order_user_url; if (r.vault_token_uid) { // IMPORTANT: save to your database linked to the user account! const saveCardData = { vault_token_uid: r.vault_token_uid, vault_card_brand: r.vault_card_brand, vault_card_umask: r.vault_card_umask, vault_exp: r.vault_exp, vault_scope: r.vault_scope, vault_onlyforuser: r.vault_onlyforuser, pay_method_uid: r.pay_method_uid }; // TODO: POST saveCardData to your backend and store in DB } } // r.status — order status (always present) }); document.addEventListener('onHPayPanelClose', function(e) { const r = e.hpay_response; // null if closed without completing const reason = String((r && r.reason) || '').toLowerCase(); // If pay button was locked during payment start, unlock it on close reasons below. if (/^(user|timeout|cancel|error)$/.test(reason)) { const payBtn = document.getElementById('do-pay'); if (payBtn) payBtn.disabled = false; } }); document.addEventListener('onHPayOrderOpExecuted', function(e) { // admin operation result }); ``` ### Thank-You Page Rendering Rule (Mandatory) On the thank-you page, always render: 1) `transaction_user_info` block first, 2) `payment_html`, `fiscal_html`, `shipping_html`, `integr_html` blocks. Bank production approval also requires a complete order summary on this page (for all outcomes: success, failed, awaiting payment): - buyer/customer identity data - billing and email data - shipping data - order number - ordered products list with quantity, unit price, and line totals - shipping cost - payment method name and shipping method name - grand total amount If any field is missing in response, keep the block visible and show a placeholder message. Keys in `transaction_user_info` can be translated for UI labels, but values should be shown exactly as received. ```javascript function renderTransactionUserInfo(info, keyMap) { const host = document.getElementById('hpay-transaction-user-info'); if (!host) return; host.innerHTML = ''; if (!info || typeof info !== 'object' || !Object.keys(info).length) { host.innerHTML = '

Transaction details are not available.

'; return; } const dl = document.createElement('dl'); Object.entries(info).forEach(([k, v]) => { const dt = document.createElement('dt'); const dd = document.createElement('dd'); dt.textContent = keyMap[k] || k; // translate keys only dd.textContent = String(v ?? ''); // do not alter values dl.appendChild(dt); dl.appendChild(dd); }); host.appendChild(dl); } renderTransactionUserInfo(r.transaction_user_info, { 'Order UID': 'Order UID', 'Payment Status': 'Payment Status', 'Transaction Time': 'Transaction Time', 'Amount in order currency': 'Amount in order currency', 'Amount in payment currency': 'Amount in payment currency', 'Bank Account': 'Bank Account', 'REF MOD97 PNB': 'REF MOD97 PNB', 'Purphose': 'Purpose' }); const FALLBACK_HTML = '

Not available for this payment.

'; document.getElementById('hpay-receipt-payment').innerHTML = r.payment_html || FALLBACK_HTML; document.getElementById('hpay-receipt-fiscal').innerHTML = r.fiscal_html || FALLBACK_HTML; document.getElementById('hpay-receipt-shipping').innerHTML = r.shipping_html || FALLBACK_HTML; document.getElementById('hpay-receipt-integr').innerHTML = r.integr_html || FALLBACK_HTML; ``` Example payload: ```json { "transaction_user_info": { "Order UID": "NIPI-1777290504591-8X6YD2", "Payment Status": "AWAITING", "Transaction Time": "2026-04-27 13:48:27.847Z", "Amount in order currency": "1360.00 RSD", "Amount in payment currency": "1360.00 RSD", "Bank Account": "160-6000002552312-02", "REF MOD97 PNB": "(97) 2726042713", "Purphose": "Order npinipi17772905045918x6yd2" } } ``` --- ## Result Verification — Webhook (Notify URL) HPay calls webhook URL via HTTP POST JSON (server-to-server). If `notify_url` is sent in request payload, it overrides panel setting: - `I(P|S|F|I)N - Link za instant notifikacije - plaćanje/isporuka/fiskal/integracije` - `I(P|S|F|I)N - Instant payment/shipping/fiscal/integation notification url` HPay appends query string parameter `topic` and sends POST JSON. Supported `topic` values: - `payresult`: same payload shape as `onHPayResult` (`e.hpay_response`) / `https://apps.holest.com/holest-pay/response_sample.json`. - `orderupdate`: root contains at least `order_uid`, `status`, `vhash`; payload fields are the same as `payresult`; and includes `order` object in the same format as `ord` from `HPay.getOrder(order_uid).then(ord => {...})` (`https://apps.holest.com/holest-pay/hpay_order_sample.json`) (`"id"` is ID from HPay system); verify `vhash` the same way. - `posconfig-updated`: contains POS config (`HPay.POS` compatible + non-public fields), `environment`, and `checkstr = md5(${merchant_site_uid}${secret_token})`. Important payload mapping note: - Request and response payloads are similar in shape; response usually contains request fields plus normalization and additional result fields. - Hash field names are different by direction: request -> `verificationhash`, response -> `vhash`. - `order` payload is a different model (same as `HPay.getOrder(...)` `ord` object), so do not map it as request/response. - Example key differences: - request/response `order_uid`, `order_amount`, `order_currency` -> order object `Uid`, `Amount`, `Currency` - request/response `order_items`, `order_billing`, `order_shipping` -> order object `Data.items`, `Data.billing`, `Data.shipping` - fiscal method specific data -> `order.FiscalData[fiscal_method_uid] = {...}` (module-specific payload) - integration method data -> `order.Data[integr_method_uid] = {...}` (may exist or may be empty/missing) - shipping method data -> `order.ShippingData[real_or_temp_shipping_code] = { method_uid: shipping_method_uid, ... }` The same payment result may arrive from browser callback and webhook. Use `vhash` (with order/transaction IDs) for idempotency to avoid duplicate processing. ### Redirect Method POST-back Note (Bank Redirect Flows) For redirect methods, HPay may return result to `order_user_url` with auto-submitted form: ```html
``` Robust parse fallback example: ```javascript function parseForwardedHPayResponse(raw) { if (!raw) return null; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch (_) {} const unescaped = String(raw).replace(/\\\\\"/g, '"').replace(/\\\\\\\\/g, '\\'); try { return JSON.parse(unescaped); } catch (_) {} return null; } ``` ## Step 5 — Verify Response `vhash` on Server (Node.js) Rule of thumb: - request to HPay (`pay_request` / `charge_request`) -> field `verificationhash` -> generate with `generatePOSRequestSignature(...)` - response from HPay (browser callback / webhook) -> field `vhash` -> validate with `verifyHPayResponse(...)` ```javascript const crypto = require('crypto'); const md5 = require('md5'); function generatePOSRequestSignature(merchant_site_uid, secretKey, payload) { const amount = Number(payload.order_amount ?? 0).toFixed(8); const src = String(payload.transaction_uid ?? '').trim() + '|' + String(payload.status ?? '').trim() + '|' + String(payload.order_uid ?? '').trim() + '|' + amount + '|' + String(payload.order_currency ?? '').trim() + '|' + String(payload.vault_token_uid ?? '').trim() + '|' + String(payload.subscription_uid ?? '').trim() + String(payload.rand ?? '').trim(); const srcMd5 = md5(src + merchant_site_uid); return crypto.createHash('sha512').update(srcMd5 + secretKey).digest('hex').toLowerCase(); } function verifyHPayResponse(result, merchant_site_uid, secretKey) { if (!result || !result.vhash) return false; if (!result.order_uid || !String(result.order_uid).trim()) return false; const expected = generatePOSRequestSignature(merchant_site_uid, secretKey, { transaction_uid: result.transaction_uid ?? '', status: result.status ?? '', order_uid: result.order_uid, order_amount: result.order_amount ?? 0, order_currency: result.order_currency ?? '', vault_token_uid: result.vault_token_uid ?? '', subscription_uid: result.subscription_uid ?? '', rand: result.rand ?? '' }); return expected === String(result.vhash).toLowerCase(); } ``` --- ## Backend Charge (Server Side — Still Requires Secret Key) FrontCore does not expose the Secret Key on the frontend, but backend charges still need it on your server. `charge_request` uses the same structure as `pay_request`. The practical difference is that `order_user_url` has no effect for backend charge (no buyer to redirect), so you should omit it. `cof` is also not needed for backend charge requests. ```javascript // Node.js backend const crypto = require('crypto'); const md5 = require('md5'); function generatePOSRequestSignature(merchant_site_uid, secretkey, request) { const amt = parseFloat(request.order_amount || 0).toFixed(8); let cstr = String(request.transaction_uid || '').trim() + '|'; cstr += String(request.status || '').trim() + '|'; cstr += String(request.order_uid || '').trim() + '|'; cstr += String(amt).trim() + '|'; cstr += String(request.order_currency || '').trim() + '|'; cstr += String(request.vault_token_uid || '').trim() + '|'; cstr += String(request.subscription_uid || '').trim(); cstr += String(request.rand || '').trim(); const md5hash = md5(cstr + merchant_site_uid); return crypto.createHash('sha512').update(md5hash + secretkey).digest('hex').toLowerCase(); } const charge_request = { merchant_site_uid: "YOUR-MERCHANT-SITE-UID", order_uid: "20260315-999999", order_amount: "15000", order_currency: "RSD", payment_method: "179", vault_token_uid: "saved-token-uuid", // required order_data: { source: "subscription-renewal" }, // optional custom data -> order.Data // same optional fields as pay_request (billing, shipping, items, notify_url, etc.) // omit order_user_url: no user exists to be redirected in backend charge flow // omit cof: not applicable for backend charge }; charge_request.verificationhash = generatePOSRequestSignature( charge_request.merchant_site_uid, SECRET_KEY, charge_request ); const baseUrl = 'https://sandbox.pay.holest.com'; // or pay.holest.com const response = await fetch(`${baseUrl}/s-blue/v1/clientpay/charge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charge_request) }); const result = await response.json(); if (/PAID|PAYING|RESERVED/.test(result.payment_status)) { // success } ``` PHP equivalent: ```php function generatePOSRequestSignature($merchant_site_uid, $secretkey, $request) { $amt = number_format((float)($request['order_amount'] ?? 0), 8, '.', ''); $cstr = trim($request['transaction_uid'] ?? '') . '|'; $cstr .= trim($request['status'] ?? '') . '|'; $cstr .= trim($request['order_uid'] ?? '') . '|'; $cstr .= trim($amt) . '|'; $cstr .= trim($request['order_currency'] ?? '') . '|'; $cstr .= trim($request['vault_token_uid'] ?? '') . '|'; $cstr .= trim($request['subscription_uid'] ?? ''); $cstr .= trim($request['rand'] ?? ''); return hash('sha512', md5($cstr . $merchant_site_uid) . $secretkey); } $charge_request['verificationhash'] = generatePOSRequestSignature( $merchant_site_uid, $secret_key, $charge_request ); $body = json_encode($charge_request); $ch = curl_init($base_url . '/s-blue/v1/clientpay/charge'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], // for simplicity only - do not disable in production: CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_SSL_VERIFYPEER => false, ]); $result = json_decode(curl_exec($ch), true); curl_close($ch); ``` --- ## Admin Operations (Requires Secret Key — Backend or Admin Page Only) For admin pages, use the **normal HPay script URL**, not the FrontCore handler script: ```html ``` Do not use `.../frontend-script-core.js` for admin tooling. ```javascript // Use the 4th parameter of HPayInit to enable admin mode HPayInit( merchant_site_uid, language, environment, secret_key // 4th param — enables admin/backend operations ).then(client => client.loadHPayUI()) .then(() => { HPay.getOrder(order_uid).then(ord => { // `ord` format sample: https://apps.holest.com/holest-pay/hpay_order_sample.json const toolbox = document.getElementById('admin_toolbox'); toolbox.innerHTML = ''; [HPay.POS.payment, HPay.POS.fiscal, HPay.POS.shipping].forEach(methods => { (methods || []).forEach(pm => { if (pm.initActions) { if (typeof pm.initActions === 'string') eval('pm.initActions = ' + pm.initActions); pm.initActions(); } if (pm.orderActions) { if (typeof pm.orderActions === 'string') eval('pm.orderActions = ' + pm.orderActions); const actions = pm.orderActions(ord); if (actions && actions.length) { const h6 = document.createElement('h6'); h6.innerHTML = pm.SystemTitle; toolbox.appendChild(h6); actions.forEach(action => { if (action.Run) { const btn = document.createElement('button'); btn.innerHTML = action.Caption; btn.addEventListener('click', e => { e.preventDefault(); action.Run(ord); }); toolbox.appendChild(btn); } else if (action.actions) { const p = document.createElement('p'); p.innerHTML = action.Caption; toolbox.appendChild(p); action.actions.forEach(sub => { const sbtn = document.createElement('button'); sbtn.innerHTML = sub.Caption; sbtn.addEventListener('click', e => { e.preventDefault(); sub.Run(ord); }); p.appendChild(sbtn); }); } }); } } }); }); }); }); ``` --- ## Difference Summary: FrontCore vs Standard | Feature | FrontCore | Standard | |---------|-----------|----------| | Script source | Auto-served from HPay server (whitelisted origin) | Manual ` ``` After loading, the global `HPay` object and the `HPayInit()` function become available. When the HPay script is loaded, it also exposes these globals on `window`: - `window.presentHPayPayForm` — function - `window.HPayIsSandbox` — environment flag variable --- ## Step 1 — Initialize HPay and Fetch POS Configuration Call `HPayInit()` once after the script loads. It returns a Promise that resolves with `client` (same reference as global `HPay`). ```javascript HPayInit( merchant_site_uid, // string — your Merchant Site UID language, // string — e.g. "en", "rs", "de" environment // string — "sandbox" or "production" ).then(async client => { // client === HPay (global) // client.POS.payment — array of payment method objects // client.POS.shipping — array of shipping method objects // client.POS.fiscal — array of fiscal method objects // Optionally filter by buyer country: let availablePayment = []; let availableShipping = []; const country = 'RS'; // ISO 2-letter code from billing or shipping address try { availablePayment = await HPay.availablePaymentMethods(country, orderAmount, orderCurrency); } catch(e) { console.error(e); } try { availableShipping = await HPay.availableShippingMethods(country, orderAmount, orderCurrency); } catch(e) { console.error(e); } // Build your payment method from client.POS.shipping: if (client.POS.shipping) { client.POS.shipping.forEach(sm => { if (!sm.Hidden && (!availableShipping.length || availableShipping.find(m => m.Uid == sm.Uid))) { // sm.HPaySiteMethodId — value to pass as pay_request.shipping_method } }); } }); ``` `HPayInit()` is safe to call multiple times — it only re-initializes if parameters change. --- ## Step 2 — Build the `pay_request` Object Collect the data from your order/checkout form: ```javascript const pay_request = { merchant_site_uid: "YOUR-MERCHANT-SITE-UID", // required hpaylang: "en", // optional UI language order_uid: "20260315-621417", // required unique order ID order_name: "#Order 204", // optional order label order_amount: "15000", // required order_currency: "RSD", // required ISO 4217 payment_method: "179", // required pm.HPaySiteMethodId shipping_method: "45", // optional sm.HPaySiteMethodId order_user_url: "https://yoursite.com/thanks", // optional redirect/thank-you URL notify_url: "https://yoursite.com/webhook", // optional public webhook URL order_data: { // optional custom payload -> stored as order.Data customer_segment: "B2C", source: "checkout-web" }, cof: "optional", // optional: optional|required|none vault_token_uid: "saved-token-uuid", // optional: saved token, or 1|true|new verificationhash: "SERVER_GENERATED_SHA512", // required in standard flow // Optional billing object (template fields): order_billing: { email: "customer@example.com", first_name: "TEST", last_name: "TEST", phone: "+38111111111", is_company: 0, company: "", // company legal name company_tax_id: "", // company tax ID in merchant's country company_reg_id: "", // company registration ID in merchant's country address: "TEST", // street name address2: "", // recommended for street number / address addition city: "Beograd", country: "RS", // ISO 2-letter code state: "Beograd", lang: "sr_RS", // language from merchant platform/system postcode: "11000" }, // Optional shipping object (template fields): order_shipping: { shippable: false, is_cod: 1, // set to 1 when COD logic is used/allowed first_name: "", last_name: "", phone: "", company: "", address: "", // street name address2: "", // recommended for street number / address addition city: "", country: "", state: "", postcode: "" }, // Optional items array (template fields): order_items: [ { posuid: 114, // merchant's own internal item ID (string or number) type: "product", name: "Sample product name", sku: "000550", qty: 1, price: 4695.99, subtotal: 4695.99, refunded: 0, refunded_qty: 0, tax_label: "", tax_amount: 0, length: "", width: "", height: "", weight: "", split_pay_uid: "", virtual: true, tax_percent: 0 } ] }; ``` Fields with empty string values should be deleted before sending: ```javascript Object.keys(pay_request).forEach(k => { if (pay_request[k] === '') delete pay_request[k]; }); ``` **Important — `order_items` naming must use HolestPay keys (do not pass raw platform keys):** - Use `name`, not `title` - Use `posuid`, not `variantId` - Use `qty`, not `quantity` - `subtotal` is mandatory for each item line - `posuid` can be any identifier from the merchant's system (SKU/variant/product/internal DB ID) Common Shopify mapping before send: - `variantId -> posuid` - `title -> name` - `quantity -> qty` - top-level `items -> order_items` The same order payload structure is used across all three markups/docs (Lovable prompt, FrontCore guide, Standard guide). Address convention recommendation: use `address` for street name and `address2` for street number/additional address details. ### Full Template Field Catalog (Standalone) - `Top-level pay_request`: - `merchant_site_uid`, `hpaylang`, `order_uid`, `order_name`, `order_amount`, `order_currency` - `payment_method`, `shipping_method`, `order_user_url`, `notify_url` - `order_data`, `cof`, `vault_token_uid`, `verificationhash` - `order_billing`: - `email`, `first_name`, `last_name`, `phone` - `is_company`, `company` (legal name), `company_tax_id` (tax ID), `company_reg_id` (registration ID) - `address`, `address2`, `city`, `country`, `state`, `postcode`, `lang` - `order_shipping`: - `shippable`, `is_cod` - `first_name`, `last_name`, `phone`, `company` - `address`, `address2`, `city`, `country`, `state`, `postcode` - `dispenser`, `dispenser_desc`, `dispenser_method_id` (locker/paket-shop flows) - `order_items[]`: - `posuid`, `type`, `name`, `sku`, `qty`, `price`, `subtotal` - Required minimum per line for reliable processing: `posuid`, `name`, `qty`, `subtotal` - `refunded`, `refunded_qty`, `tax_label`, `tax_amount` - `length`, `width`, `height`, `weight`, `split_pay_uid`, `virtual`, `warehouse` - `setPaymentMethodDock(...)` data: - `order_amount`, `order_currency`, `monthly_installments`, `vault_token_uid`, `hpaylang`, `cof` - Signature input/result fields: - `transaction_uid`, `status`, `order_uid`, `order_amount`, `order_currency`, `vault_token_uid`, `subscription_uid`, `rand` - For result validation compare computed signature with result `vhash` (not request `verificationhash`). - `Extensibility`: - `order_data` may contain any custom key/value pairs; it is persisted to `order.Data` in HPay order. - `order_billing` and `order_shipping` may include additional custom fields besides the listed ones. - Total serialized order payload should stay below **64 KB**. --- ## Step 3 — Generate the Signature (Server Side!) **NEVER generate the signature on the frontend in production.** The Secret Key must stay on the server. ### PHP (server side) ```php function generatePOSRequestSignature($merchant_site_uid, $secretkey, $request) { $amt = number_format((float)($request['order_amount'] ?? 0), 8, '.', ''); $cstr = trim($request['transaction_uid'] ?? '') . '|'; $cstr .= trim($request['status'] ?? '') . '|'; $cstr .= trim($request['order_uid'] ?? '') . '|'; $cstr .= trim($amt) . '|'; $cstr .= trim($request['order_currency'] ?? '') . '|'; $cstr .= trim($request['vault_token_uid'] ?? '') . '|'; $cstr .= trim($request['subscription_uid'] ?? ''); $cstr .= trim($request['rand'] ?? ''); $md5 = md5($cstr . $merchant_site_uid); return hash('sha512', $md5 . $secretkey); } // Usage — add signature hash to pay_request before returning to browser: $pay_request['verificationhash'] = generatePOSRequestSignature( $merchant_site_uid, $secret_key, $pay_request ); ``` ### Node.js (server side) ```javascript const crypto = require('crypto'); const md5 = require('md5'); // npm install md5 function generatePOSRequestSignature(merchant_site_uid, secretkey, request) { const amt = parseFloat(request.order_amount || 0).toFixed(8); let cstr = String(request.transaction_uid || '').trim() + '|'; cstr += String(request.status || '').trim() + '|'; cstr += String(request.order_uid || '').trim() + '|'; cstr += String(amt).trim() + '|'; cstr += String(request.order_currency || '').trim() + '|'; cstr += String(request.vault_token_uid || '').trim() + '|'; cstr += String(request.subscription_uid || '').trim(); cstr += String(request.rand || '').trim(); const cstrmd5 = md5(cstr + merchant_site_uid); return crypto.createHash('sha512').update(cstrmd5 + secretkey).digest('hex').toLowerCase(); } pay_request.verificationhash = generatePOSRequestSignature( merchant_site_uid, secret_key, pay_request ); ``` Also available as `HPay.generatePOSRequestSignature(request)` if HPay was initialized with the secret key as the 4th parameter (only for admin/backend use cases). --- ## Step 4 — Present the Payment Form After receiving the signed `pay_request` from your server: ```javascript // Option A: Modal (default) HPay.presentHPayPayForm(pay_request); // Option B: Docked (embedded in page element) — only if pm.PayInputUrl is set const dockElement = document.getElementById('paymentMethodDock'); HPay.setPaymentMethodDock( pay_request.payment_method, { order_amount: pay_request.order_amount, order_currency: pay_request.order_currency, monthly_installments: null, // force instalment number, or null to let buyer choose vault_token_uid: pay_request.vault_token_uid || null, hpaylang: pay_request.hpaylang, cof: pay_request.cof }, dockElement // container element or selector ); // Then trigger payment (e.g. on a "Pay" button click): HPay.presentHPayPayForm(pay_request); ``` For the dock container, add this CSS so it has a visible background: ```css #paymentMethodDock { background: #ffffff9e; } ``` --- ## Step 5 — Handle the Result Events Listen for these document-level custom events: ```javascript // Fires when payment result arrives (success OR error) document.addEventListener('onHPayResult', function(e) { const r = e.hpay_response; if (!r) return; if (r.error && r.error.code) { // payment failed — can retry HPay.presentHPayPayForm(pay_request); // re-open form return; } // r.payment_status — e.g. "PAID", "RESERVED", "PAYING", "OBLIGATED", "AWAITING" if (/PAID|RESERVED|SUCCESS|PAYING|OBLIGATED|AWAITING/i.test(r.payment_status)) { // SUCCESS — fulfil the order // window.location.href = r.order_user_url; // redirect to thank-you page // IMPORTANT: clear cart for PAID/RESERVED and also for PAYING/AWAITING/OBLIGATED. // r.payment_html — HTML receipt / payment instructions from payment provider // r.fiscal_html — HTML fiscal receipt (if fiscal module active) // r.integr_html — Integration HTML // r.shipping_html — Shipping HTML // IMPORTANT: AWAITING/OBLIGATED are NOT failed; show r.payment_html on thank-you page // (commonly account/invoice payment details) // r.transaction_user_info — object with card/transaction details // e.g. { card_brand: "MASTERCARD", masked_pan: "544358******4639", ... } // Vault token (card-on-file) — if user agreed to save card: if (r.vault_token_uid) { // IMPORTANT: save to your database linked to the user account! const saveCardData = { vault_token_uid: r.vault_token_uid, vault_card_brand: r.vault_card_brand, // e.g. "MASTERCARD" vault_card_umask: r.vault_card_umask, // masked PAN e.g. "544358******4639" vault_exp: r.vault_exp, // e.g. "12/26" vault_scope: r.vault_scope, // acquirer scope / merchant ID vault_onlyforuser: r.vault_onlyforuser, // 1 = tied to specific user pay_method_uid: r.pay_method_uid // payment method this token belongs to }; // TODO: POST saveCardData to your backend and store in DB } } // Always: r.status — order status string // Verify result signature before trusting it: // const valid = HPay.verifyPOSResultSignature(r); // if secret key was passed to HPayInit }); // Fires when the payment panel is closed (by user or after result) document.addEventListener('onHPayPanelClose', function(e) { const r = e.hpay_response; // may be null if closed without completing const reason = String((r && r.reason) || '').toLowerCase(); // If pay button was locked during payment start, unlock it on close reasons below. if (/^(user|timeout|cancel|error)$/.test(reason)) { const payBtn = document.getElementById('do-pay'); if (payBtn) payBtn.disabled = false; } }); // Fires when an admin operation (via orderActions) completes document.addEventListener('onHPayOrderOpExecuted', function(e) { // e.hpay_response — result of the admin operation }); ``` ### Redirect Method POST-back Note (Bank Redirect Flows) For redirect payment methods, HPay may return the result to `order_user_url` using an auto-submitted POST form: ```html
``` Because some stacks may escape the JSON string, implement robust parsing with normalization fallback: ```javascript function parseForwardedHPayResponse(raw) { if (!raw) return null; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch (_) {} const unescaped = String(raw).replace(/\\\\\"/g, '"').replace(/\\\\\\\\/g, '\\'); try { return JSON.parse(unescaped); } catch (_) {} return null; } ``` ### Thank-You Page Rendering Rule (Mandatory) On the thank-you page, always render: 1) `transaction_user_info` block first, 2) `payment_html`, `fiscal_html`, `shipping_html`, `integr_html` blocks. Bank production approval also requires a complete order summary on this page (for all outcomes: success, failed, awaiting payment): - buyer/customer identity data - billing and email data - shipping data - order number - ordered products list with quantity, unit price, and line totals - shipping cost - payment method name and shipping method name - grand total amount If any field is missing in response, keep the block visible and show a placeholder message. Keys in `transaction_user_info` can be translated for UI labels, but values should be shown exactly as received. ```javascript function renderTransactionUserInfo(info, keyMap) { const host = document.getElementById('hpay-transaction-user-info'); if (!host) return; host.innerHTML = ''; if (!info || typeof info !== 'object' || !Object.keys(info).length) { host.innerHTML = '

Transaction details are not available.

'; return; } const dl = document.createElement('dl'); Object.entries(info).forEach(([k, v]) => { const dt = document.createElement('dt'); const dd = document.createElement('dd'); dt.textContent = keyMap[k] || k; // translate keys only dd.textContent = String(v ?? ''); // do not alter values dl.appendChild(dt); dl.appendChild(dd); }); host.appendChild(dl); } renderTransactionUserInfo(r.transaction_user_info, { 'Order UID': 'Order UID', 'Payment Status': 'Payment Status', 'Transaction Time': 'Transaction Time', 'Amount in order currency': 'Amount in order currency', 'Amount in payment currency': 'Amount in payment currency', 'Bank Account': 'Bank Account', 'REF MOD97 PNB': 'REF MOD97 PNB', 'Purphose': 'Purpose' }); const FALLBACK_HTML = '

Not available for this payment.

'; document.getElementById('hpay-receipt-payment').innerHTML = r.payment_html || FALLBACK_HTML; document.getElementById('hpay-receipt-fiscal').innerHTML = r.fiscal_html || FALLBACK_HTML; document.getElementById('hpay-receipt-shipping').innerHTML = r.shipping_html || FALLBACK_HTML; document.getElementById('hpay-receipt-integr').innerHTML = r.integr_html || FALLBACK_HTML; ``` Example payload: ```json { "transaction_user_info": { "Order UID": "NIPI-1777290504591-8X6YD2", "Payment Status": "AWAITING", "Transaction Time": "2026-04-27 13:48:27.847Z", "Amount in order currency": "1360.00 RSD", "Amount in payment currency": "1360.00 RSD", "Bank Account": "160-6000002552312-02", "REF MOD97 PNB": "(97) 2726042713", "Purphose": "Order npinipi17772905045918x6yd2" } } ``` --- ## Step 6 — Verify the Result Signature (Server Side) Always verify result `vhash` on your server before fulfilling the order. Rule of thumb: - request to HPay (`pay_request` / `charge_request`) -> field `verificationhash` -> generate with `generatePOSRequestSignature(...)` - response from HPay (browser callback / webhook) -> field `vhash` -> validate with `verifyHPayResponse(...)` ```javascript // Node.js const crypto = require('crypto'); const md5 = require('md5'); function generatePOSRequestSignature(merchant_site_uid, secretKey, payload) { const amount = Number(payload.order_amount ?? 0).toFixed(8); const src = String(payload.transaction_uid ?? '').trim() + '|' + String(payload.status ?? '').trim() + '|' + String(payload.order_uid ?? '').trim() + '|' + amount + '|' + String(payload.order_currency ?? '').trim() + '|' + String(payload.vault_token_uid ?? '').trim() + '|' + String(payload.subscription_uid ?? '').trim() + String(payload.rand ?? '').trim(); const srcMd5 = md5(src + merchant_site_uid); return crypto.createHash('sha512').update(srcMd5 + secretKey).digest('hex').toLowerCase(); } function verifyHPayResponse(result, merchant_site_uid, secretKey) { if (!result || !result.vhash) return false; if (!result.order_uid || !String(result.order_uid).trim()) return false; const expected = generatePOSRequestSignature(merchant_site_uid, secretKey, { transaction_uid: result.transaction_uid ?? '', status: result.status ?? '', order_uid: result.order_uid, order_amount: result.order_amount ?? 0, order_currency: result.order_currency ?? '', vault_token_uid: result.vault_token_uid ?? '', subscription_uid: result.subscription_uid ?? '', rand: result.rand ?? '' }); return expected === String(result.vhash).toLowerCase(); } ``` --- ## Backend Charge (Server Side, for COF/MIT) When a payment method supports `pm.POps` containing `"charge"` and a `vault_token_uid` has been saved, you can charge the card without user interaction. `charge_request` uses the same structure as `pay_request`. The practical difference is that `order_user_url` has no effect for backend charge (no buyer to redirect), so you should omit it. `cof` is also not needed for backend charge requests. ```javascript // Node.js backend const crypto = require('crypto'); const md5 = require('md5'); const charge_request = { merchant_site_uid: "YOUR-MERCHANT-SITE-UID", hpaylang: "en", order_uid: "20260315-999999", order_name: "#Order 205", order_amount: "15000", order_currency: "RSD", payment_method: "179", vault_token_uid: "saved-token-uuid", // required for charge order_data: { source: "subscription-renewal" }, // optional custom data -> order.Data // same optional fields as pay_request (billing, shipping, items, notify_url, etc.) // omit order_user_url: no user exists to be redirected in backend charge flow // omit cof: not applicable for backend charge }; charge_request.verificationhash = generatePOSRequestSignature( charge_request.merchant_site_uid, SECRET_KEY, charge_request ); const baseUrl = 'https://sandbox.pay.holest.com'; // or pay.holest.com const response = await fetch(`${baseUrl}/s-blue/v1/clientpay/charge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charge_request) }); const result = await response.json(); // Verify result signature before fulfilling: // const valid = verifyHPayResponse(result, merchant_site_uid, SECRET_KEY); if (/PAID|PAYING|RESERVED/.test(result.payment_status)) { // success — fulfil the order } ``` --- ## Admin Operations (Order Management) To access admin-level order operations (refund, fiscal print, shipping confirm, etc.): ```javascript HPayInit( merchant_site_uid, language, environment, secret_key // 4th parameter enables admin/backend operations ).then(client => client.loadHPayUI()) .then(() => { HPay.getOrder(order_uid).then(ord => { // `ord` format sample: https://apps.holest.com/holest-pay/hpay_order_sample.json const toolbox = document.getElementById('admin_toolbox'); toolbox.innerHTML = ''; [HPay.POS.payment, HPay.POS.fiscal, HPay.POS.shipping].forEach(methods => { (methods || []).forEach(pm => { if (pm.initActions) { if (typeof pm.initActions === 'string') eval('pm.initActions = ' + pm.initActions); pm.initActions(); } if (pm.orderActions) { if (typeof pm.orderActions === 'string') eval('pm.orderActions = ' + pm.orderActions); const actions = pm.orderActions(ord); if (actions && actions.length) { const h6 = document.createElement('h6'); h6.innerHTML = pm.SystemTitle; toolbox.appendChild(h6); actions.forEach(action => { if (action.Run) { const btn = document.createElement('button'); btn.innerHTML = action.Caption; btn.addEventListener('click', e => { e.preventDefault(); action.Run(ord); }); toolbox.appendChild(btn); } else if (action.actions) { const p = document.createElement('p'); p.innerHTML = action.Caption; toolbox.appendChild(p); action.actions.forEach(sub => { const sbtn = document.createElement('button'); sbtn.innerHTML = sub.Caption; sbtn.addEventListener('click', e => { e.preventDefault(); sub.Run(ord); }); p.appendChild(sbtn); }); } }); } } }); }); }); }); ``` --- ## Result Verification — Webhook (Notify URL) HPay calls `notify_url` via HTTP POST with a JSON body (server-to-server). The URL must be publicly accessible from the internet. The structure is the same as `onHPayResult`'s `e.hpay_response`. Always verify response `vhash` before processing. If `notify_url` is sent in request payload, it overrides the panel-level `I(P|S|F|I)N` instant notification URL for that request. In HPay panel site settings, configure instant notification URL under: - `I(P|S|F|I)N - Link za instant notifikacije - plaćanje/isporuka/fiskal/integracije` - `I(P|S|F|I)N - Instant payment/shipping/fiscal/integation notification url` HPay appends query string parameter `topic` to that URL and sends POST JSON. Supported `topic` values: - `payresult`: payload shape is the same as `onHPayResult` (`e.hpay_response`), sample: `https://apps.holest.com/holest-pay/response_sample.json`. - `orderupdate`: root payload always includes `order_uid`, `status`, and `vhash`; payload fields are the same as `payresult`; includes `order` property with HolestPay order object in the same format as `ord` from `HPay.getOrder(order_uid).then(ord => {...})`, sample: `https://apps.holest.com/holest-pay/hpay_order_sample.json` (`"id"` is ID from HPay system). Signature verification is the same (`vhash` validation). - `posconfig-updated`: contains POS config (same structure as `HPay.POS` plus non-public fields), `environment`, and `checkstr = md5(${merchant_site_uid}${secret_token})`. Important payload mapping note: - Request and response payloads are similar in shape; response usually contains request fields plus normalization and additional result fields. - Hash field names are different by direction: request -> `verificationhash`, response -> `vhash`. - `order` payload is a different model (same as `HPay.getOrder(...)` `ord` object), so do not map it as request/response. - Example key differences: - request/response `order_uid`, `order_amount`, `order_currency` -> order object `Uid`, `Amount`, `Currency` - request/response `order_items`, `order_billing`, `order_shipping` -> order object `Data.items`, `Data.billing`, `Data.shipping` - fiscal method specific data -> `order.FiscalData[fiscal_method_uid] = {...}` (module-specific payload) - integration method data -> `order.Data[integr_method_uid] = {...}` (may exist or may be empty/missing) - shipping method data -> `order.ShippingData[real_or_temp_shipping_code] = { method_uid: shipping_method_uid, ... }` The same payment result can arrive via browser callback and via webhook. Use `vhash` (plus order/transaction identifiers) as idempotency key to prevent duplicate processing. --- ## Implementation Precision Addendum Use this section as an execution checklist to reduce integration mistakes and speed up debugging. ### Request Validation Rules (Before Calling HPay) - `order_uid` must be unique per order attempt in your system; do not reuse successful IDs for new purchases. - `merchant_site_uid` must belong to the same environment where script/API is used (`sandbox` with sandbox, `production` with production). - `order_amount` must be the same value across all stages for one request lifecycle (form -> signed payload -> audit logs). - `order_currency` must be a valid ISO 4217 code configured on your POS methods. - `payment_method` must be `pm.HPaySiteMethodId` (not `pm.Uid`). - `shipping_method` should be omitted if shipping is not used; send it only when user selected a supported shipping option. - `notify_url` should be a stable server endpoint; avoid temporary dev tunnels for production behavior testing. - `order_user_url` should point to your own order/thank-you page and include enough context to restore order state. - `order_data` is ideal for stable metadata like source channel, cart fingerprint, CRM ID, campaign tags, or internal correlation keys. - Remove empty fields before sending; avoid null/empty noise in logs and signatures. ### Signature and Verification Precision - Generate `verificationhash` server-side only for requests sent to HPay. - For responses received from HPay, always validate `vhash`. - Use exactly the documented signature field order and amount formatting (`toFixed(8)` / `number_format(..., 8)`). - Compare signatures using exact lowercase hex strings. - Verify signatures in both places: browser event result handling path and webhook path. - Treat unsigned or signature-invalid payload as non-authoritative and do not fulfill order. - Log both the received and calculated signature in secure server logs for incident analysis. ### Webhook Reliability Pattern - Implement webhook idempotency keyed by transaction/order identifiers from result payload. - Accept webhook retry behavior; return success response only after durable persistence. - Process business actions once, even if webhook arrives multiple times or after browser callback. - Record webhook receive timestamp and raw payload snapshot for auditability. - Never rely on callback ordering between browser event and webhook; either can arrive first. - Use webhook as final server authority for fulfillment transitions. ### Status Handling Strategy - Handle `PAID`, `SUCCESS`, `PAYING`, `RESERVED`, `OBLIGATED`, `AWAITING`, `FAILED`, `REFUSED`, `CANCELED`, `VOID`, `REFUNDED`, `PARTIALLY-REFUNDED`, `EXPIRED`, `OVERDUE`. - Map payment status to business actions explicitly: - `PAID`/`SUCCESS`: fulfill when signature is valid. - `PAYING`: partially paid; keep order open according to your policy. - `RESERVED`: authorization only; wait for capture before final fulfillment. - `AWAITING`/`OBLIGATED`: pending/offline or legally committed flows; these are **not failed** statuses. Do not treat them as card-capture success, but show `payment_html` on thank-you page (often contains account/invoice payment instructions). - Cart handling rule: clear cart for `PAID`, `RESERVED`, `PAYING`, `AWAITING`, and `OBLIGATED` (same cart behavior for all these statuses). - `FAILED`/`REFUSED`/`CANCELED`/`EXPIRED`/`OVERDUE`: stop fulfillment and show retry/payment-link path. - Parse composed HolestPay status (`PAYMENT ... _FISCAL/_INTEGR ... _SHIPPING ...`) as operational context, not payment result only. ### Shipping and Dispenser Precision - `dispenser`, `dispenser_desc`, and `dispenser_method_id` are meaningful only for shipping methods that support locker/paket-shop mode. - `dispenser_method_id` must match top-level `shipping_method` to be considered in downstream processing. - Keep shipping address and shipping method logically aligned (country/state/postcode compatibility). ### Payload Size and Data Modeling - Keep total serialized order payload below `64 KB`. - Prefer short stable keys in `order_data`; avoid embedding huge documents. - Do not place sensitive secrets in `order_data`, `order_items`, or any client-visible payload field. - Put only metadata needed for reconciliation/debugging into request payload. ### Frontend and Backend Responsibility Split - Frontend: collect user choices, call initialized HPay APIs, listen to events, render status. - Backend: create signature, verify result signatures, process webhook, enforce idempotency, persist state. - Admin operations (`HPayInit(..., secret_key)`) belong to protected/admin contexts only. ### Recommended Test Matrix (Minimal but Complete) - Test with `sandbox` and at least two payment methods configured on POS. - Test success payment and failed payment (validation/card failure). - Test close panel before completion and verify no accidental fulfillment. - Test retry after `onHPayResult` error flow. - Test with and without shipping section. - Test one request containing `order_data` and confirm data appears in resulting order `Data`. - Test one backend charge request with existing `vault_token_uid`. - Test webhook replay (send same payload twice) and confirm idempotent behavior. - Test signature mismatch scenario and verify order is not fulfilled. - Test one `RESERVED`/authorization-capable method if available and confirm capture-dependent flow. ### Logging and Observability - Log correlation tuple for each request: `order_uid`, `merchant_site_uid`, environment, method IDs, and internal user/session key. - Store raw HPay response payloads for troubleshooting (with sensitive-data policy applied). - Keep separate logs for browser callback processing and webhook processing. - Add clear audit events: `PAYMENT_INIT`, `PAYMENT_RESULT_RECEIVED`, `WEBHOOK_RECEIVED`, `SIGNATURE_VALIDATED`, `FULFILLMENT_TRIGGERED`. ### Common Integration Mistakes to Avoid - Using `pm.Uid` instead of `pm.HPaySiteMethodId`. - Mixing sandbox credentials with production script/API domain. - Trusting browser callback without server-side verification. - Reusing old `order_uid` across retries/orders. - Treating all non-error statuses as final success. - Sending huge custom payloads over `64 KB`. ### Field Constraints Quick Reference | Field | Required | Typical format | Precision note | |---|---|---|---| | `merchant_site_uid` | Yes | UUID-like string | Must match target environment POS | | `hpaylang` | No | `en`, `rs`, `de`, ... | Keep consistent across UI and requests | | `order_uid` | Yes | App-specific unique string | Unique per purchase attempt | | `order_name` | No | Human-readable label | Useful for support and logs | | `order_amount` | Yes | Numeric string | Use same value across client/server | | `order_currency` | Yes | ISO 4217 | Must be supported by selected method | | `payment_method` | Yes | `HPaySiteMethodId` string | Never use `pm.Uid` here | | `shipping_method` | No | `HPaySiteMethodId` string | Send only when shipping is used | | `order_user_url` | No | HTTPS URL | Browser flow only | | `notify_url` | No | Public HTTPS URL | Webhook authority endpoint | | `order_data` | No | Object/map | Stored in resulting order `Data` | | `cof` | No | `optional|required|none` | Checkout card-save behavior | | `vault_token_uid` | No | Token UUID or `1|true|new` | Saved card or save-request hint | | `verificationhash` | Yes (standard pay) | SHA-512 lowercase hex | Generated server-side | | `order_billing.email` | No | Email | Validate syntax before send | | `order_billing.phone` | No | E.164-like | Keep normalized | | `order_billing.country` | No | ISO-2 | Use uppercase (`RS`, `US`) | | `order_shipping.country` | No | ISO-2 | Align with shipping method support | | `order_shipping.dispenser` | No | Provider ID | Locker/paket-shop only | | `order_shipping.dispenser_method_id` | No | Method ID string | Must match `shipping_method` | | `order_items[].posuid` | Yes | String/number | Merchant-defined internal item identifier (not a fixed HolestPay enum) | | `order_items[].name` | Yes | String | Human-readable item name (not `title`) | | `order_items[].qty` | Yes | Number/string | Positive quantity expected (not `quantity`) | | `order_items[].price` | No | Number/string | Keep pricing model consistent | | `order_items[].subtotal` | Yes | Number/string | Mandatory line subtotal | | `order_items[].virtual` | No | `0|1` | Shipping relevance hint | ### Server Workflow Blueprint (Standard) ```text 1) Frontend builds draft pay_request (without verificationhash). 2) Frontend sends draft payload to your backend /payments/init endpoint. 3) Backend validates required fields + method compatibility + environment. 4) Backend generates verificationhash using secret key. 5) Backend persists "payment initiated" row with internal correlation ID. 6) Backend returns signed pay_request to frontend. 7) Frontend calls HPay.presentHPayPayForm(signed_request). 8) Frontend receives onHPayResult and forwards payload to backend for logging. 9) Backend verifies signature on result payload. 10) Webhook arrives (possibly before/after browser callback). 11) Backend verifies webhook signature and applies idempotent state transition. 12) Fulfillment runs only once after verified terminal/success policy conditions. 13) Audit events are written for every transition. ``` ### Idempotent Webhook Handling Example (Node.js Pseudocode) ```javascript // Pseudocode only: adapt to your DB and framework. app.post('/webhooks/hpay', async (req, res) => { const payload = req.body || {}; const orderUid = String(payload.order_uid || ''); const txUid = String(payload.transaction_uid || ''); const idempotencyKey = `${orderUid}:${txUid}:${payload.status || ''}`; if (!orderUid) return res.status(400).json({ ok: false, error: 'missing order_uid' }); // 1) Verify signature before business logic const calculated = generatePOSRequestSignature(MERCHANT_SITE_UID, SECRET_KEY, payload); const valid = calculated === String(payload.vhash || '').toLowerCase(); if (!valid) return res.status(400).json({ ok: false, error: 'invalid signature' }); // 2) Idempotency lock const alreadyProcessed = await db.idempotency.exists(idempotencyKey); if (alreadyProcessed) return res.status(200).json({ ok: true, duplicate: true }); // 3) Persist first, then act await db.tx(async trx => { await trx.idempotency.insert({ key: idempotencyKey, createdAt: new Date() }); await trx.hpayEvents.insert({ orderUid, transactionUid: txUid, paymentStatus: payload.payment_status || null, orderStatus: payload.status || null, raw: JSON.stringify(payload), source: 'webhook' }); const paymentStatus = String(payload.payment_status || '').toUpperCase(); if (/PAID|SUCCESS|PAYING|RESERVED|OBLIGATED/.test(paymentStatus)) { await trx.orders.markAsPaidOrReserved(orderUid, paymentStatus); // Optional: enqueue downstream workers instead of inline execution await trx.jobs.enqueue({ type: 'FULFILLMENT_DECISION', orderUid }); } else if (/AWAITING/.test(paymentStatus)) { // Not failed: keep order pending and show payment_html instructions on return/thank-you page await trx.orders.markAsPendingPaymentInstructions(orderUid, paymentStatus); } else { await trx.orders.markAsPaymentNotCompleted(orderUid, paymentStatus); } }); return res.status(200).json({ ok: true }); }); ``` ### Browser Callback + Webhook Reconciliation Rules - If browser callback succeeds first, store it as provisional until webhook check completes. - If webhook succeeds first, mark server truth immediately; browser callback only enriches logs/UI. - If callback says success but webhook never arrives, run delayed status query before fulfillment. - If callback and webhook disagree, trust verified server-side signature + latest authoritative status policy. - Never ship goods exclusively from client-side event data. ### Operational State Model (Recommended) - `ORDER_CREATED`: order exists locally before payment started. - `PAYMENT_INITIATED`: signed request issued to browser. - `PAYMENT_UI_OPENED`: optional telemetry state. - `PAYMENT_CALLBACK_RECEIVED`: browser event stored. - `WEBHOOK_RECEIVED`: webhook accepted and verified. - `PAYMENT_CONFIRMED`: business rule says financial completion met. - `FULFILLMENT_PENDING`: queued for shipping/fiscal/integration actions. - `FULFILLED`: goods/services delivered or process completed. - `PAYMENT_FAILED`: terminal non-success status. - `CLOSED`: final immutable state with audit trail. ### Production Readiness Checklist - Rotate and store Secret Key in secure secret manager, not source code. - Restrict admin pages that use `HPayInit(..., secret_key)` by authentication/authorization. - Add request/response size limits and payload schema validation at backend edge. - Enable TLS-only endpoints for checkout and webhook. - Add monitoring alarms for webhook signature failures and payment failure spikes. - Capture dashboard metrics: conversion rate by method, callback-to-webhook lag, duplicate webhook rate. - Create runbook for manual recovery: status query, refund/void decisions, replay-safe reprocessing. - Test environment cutover with explicit checklist (POS UID, methods, notify URLs, allowed domains, secrets). ### Troubleshooting Cookbook - Symptom: payment modal does not open. - Check: script URL host, browser console errors, `HPayInit` resolved, `payment_method` selected. - Fix: ensure `hpay.js` loaded from correct environment and request has required fields. - Symptom: result arrives in browser but order not updated on server. - Check: callback forwarding endpoint, webhook delivery logs, signature verification result. - Fix: persist callback as provisional and complete state transition on verified webhook. - Symptom: signature mismatch on server. - Check: amount normalization to 8 decimals, field order in signature string, correct secret key/environment. - Fix: use one shared signature utility across all server handlers and add test vectors. - Symptom: webhook endpoint receives duplicates. - Check: idempotency key existence and transaction wrapping around insert/process logic. - Fix: move idempotency write before business action and enforce unique DB constraint on key. - Symptom: shipping actions are unavailable or inconsistent. - Check: selected `shipping_method`, configured shipping module, country constraints, item/shipping flags. - Fix: refresh POS config via `HPayInit`, re-evaluate available shipping methods per country/amount. - Symptom: charge request fails with saved token. - Check: token validity/scope, method supports `charge`, signature correctness, environment match. - Fix: use fresh token tied to same merchant scope and verify method `POps` includes `charge`. - Symptom: user is not redirected to thank-you URL. - Check: method iframe/redirect behavior and callback handling. - Fix: redirect manually in success branch using `r.order_user_url` when appropriate. - Symptom: fiscal/shipping statuses are missing in composed status. - Check: module actually enabled and configured to emit status. - Fix: treat missing module segment as valid; do not hard-fail parsing logic. ### Suggested CI/QA Validation Cases - Validate JSON schema for `pay_request` and `charge_request` in unit tests. - Validate signature generation against fixed fixtures in PHP and Node.js implementations. - Validate idempotency behavior by replaying same webhook payload at least 3 times. - Validate parser for composed status strings with and without fiscal/shipping segments. - Validate route-level access control for admin operations requiring Secret Key. - Validate payload size guard rejects requests above `64 KB`. - Validate environment guard rejects sandbox credentials on production host and vice versa. - Validate correlation logs always include `order_uid` and `merchant_site_uid`. ### Security No-Go List - Never hardcode `secret_key` in browser JavaScript, HTML templates, or mobile bundle assets. - Never process fulfillment on unverified callback payload. - Never expose raw webhook endpoint without rate limiting and request body size limits. - Never trust query params from `order_user_url` alone as proof of payment. - Never log secret values or full PAN/cardholder data in application logs. - Never reuse one `order_uid` for multiple independent purchases. ### Terminology Quick Map - `POS` in HolestPay = your app/site sales endpoint, not a physical cashier terminal. - `payment_method` = HPay method instance ID selected by buyer. - `status` = composed platform state across payment/fiscal/shipping modules. - `payment_status` = payment module status focus. - `verificationhash` = request signature hash sent to HPay. - `vhash` = response signature hash returned by HPay. - `order_data` = custom merchant metadata persisted to order `Data`. --- ## Key Object Reference All fields supported by the template are listed in `Step 2` under `Full Template Field Catalog (Standalone)`. --- ## Important Notes - **Secret Key must never appear in frontend code** in production. Use it only on your server. - The page must be served over **HTTPS**. HPay will not work on `file://` or plain HTTP. - `HPayInit()` can be called multiple times safely — only re-inits on parameter change. - `payment_method` value is `pm.HPaySiteMethodId` (NOT `pm.Uid` — those are different). - `availablePaymentMethods(country, amount, currency)` and `availableShippingMethods(...)` filter by buyer's country and order details. Pass billing country for payment, shipping country for shipping. - If `pm['Use IFRAME'] === false`, the method uses a redirect flow — your page cannot accept the postback result directly. This method type requires proper server-side integration. - After a successful payment, generate a new `order_uid` for the next order — never reuse order UIDs. - `order_data` supports custom metadata and is stored as HPay order `Data`. - `order_billing` and `order_shipping` can include additional custom fields, but keep total serialized order payload under **64 KB**. --- ## Required for Bank Production Approval — Logotypes and Terms of Service > ?? **This section has nothing to do with payment functionality** — payments work without it. However, **almost all banks in the region will require these elements to be present on the site before approving the POS for production use.** Implement these alongside the payment integration. --- ### Footer Logotypes (Card logos, Bank logos, 3DS logos) Banks require card brand logos, acquiring bank logos, and 3DS security logos to appear in the site footer — in a single horizontal line, **no taller than approximately 1 cm visually**. Order: card logos first, bank logos in the middle (with some spacing on both sides), 3DS logos on the right. Bank and 3DS logos must be linked. The footer strip must be visible on **all pages of the website**, not only on checkout. HolestPay provides all logo image URLs and link targets via `HPay.POS.pos_parameters` after `HPayInit()`. Use them — do not source logos elsewhere. **CSS for the footer branding strip:** ```css .hpay_footer_branding_wrapper { width: 100%; padding: 8px 0; border-top: 1px solid #e0e0e0; } .hpay_footer_branding { display: flex; align-items: center; gap: 0; flex-wrap: nowrap; } .hpay-footer-branding-cards, .hpay-footer-branding-bank, .hpay-footer-branding-3ds { display: flex; align-items: center; flex-wrap: nowrap; } .hpay-footer-branding-bank { margin: 0 12px; } .hpay_footer_branding img { height: 1cm; width: auto; display: inline-block; } ``` **JavaScript — call this after `HPayInit()` resolves:** ```javascript function renderHPayFooterLogotypes() { if (!(typeof HPay !== 'undefined' && HPay && HPay.POS)) return; if (!HPay.POS.pos_parameters) return; let card_images_html = ''; let banks_html = ''; let threes_html = ''; if (HPay.POS.pos_parameters['Logotypes Card Images']) { HPay.POS.pos_parameters['Logotypes Card Images'].split('\n').forEach(src => { if (src.trim()) card_images_html += `Card`; }); } if (HPay.POS.pos_parameters['Logotypes Banks']) { HPay.POS.pos_parameters['Logotypes Banks'].split('\n').forEach(line => { if (!line.trim()) return; // format: "imageUrl:linkUrl" — colons in URLs are escaped before splitting const t = line.replace(/https:/gi, '-PS-').replace(/http:/gi, '-P-') .split(':').map(r => r.replace(/-P-/g, 'http:').replace(/-PS-/g, 'https:')); banks_html += t.length > 1 ? `Bank` : `Bank`; }); } if (HPay.POS.pos_parameters['Logotypes 3DS']) { HPay.POS.pos_parameters['Logotypes 3DS'].split('\n').forEach(line => { if (!line.trim()) return; const t = line.replace(/https:/gi, '-PS-').replace(/http:/gi, '-P-') .split(':').map(r => r.replace(/-P-/g, 'http:').replace(/-PS-/g, 'https:')); threes_html += t.length > 1 ? `3DS` : `3DS`; }); } const logotypesDiv = document.createElement('div'); logotypesDiv.className = 'hpay_footer_branding'; logotypesDiv.innerHTML = `` + `` + ``; const wrapper = document.createElement('div'); wrapper.className = 'hpay_footer_branding_wrapper'; wrapper.appendChild(logotypesDiv); (document.querySelector('footer') || document.querySelector('main') || document.body) .appendChild(wrapper); } // Call after HPayInit resolves: // HPayInit().then(client => { renderHPayFooterLogotypes(); }); ``` --- ### Terms of Service Acceptance Checkbox Almost all banks in the region require the customer to explicitly accept the site Terms of Service before payment — typically a checkbox with a link to the TOS content. **HolestPay provides a ready-to-use Terms of Service / Purchase Conditions template page for each POS/site:** ``` https://sandbox.pay.holest.com/clientpay/tos/?lang=rs https://pay.holest.com/clientpay/tos/?lang=en ``` Supported `lang` values: `en`, `rs`, `bs`, `hr`, `me`, `de`, `es`, `gr`, `tr`, `mk`, and others. This URL returns HTML content (no `` wrapper) and can be: - Loaded in an `