Skip to content

Nexi XPay Greece — Hosted Payment Page Integration

Flow ID: IN-23 | Module(s): checkout, gift_cards, job, eshop | Complexity: High Last Updated: 2026-05-21

Business Overview

Nexi XPay Greece is a card-payment gateway used by Greek merchants. The integration uses the Hosted Payment Page (HPP) model: the platform creates an order on the Nexi API, receives a redirect URL, and hands the browser off to Nexi's own checkout page. Nexi handles all card-number entry and 3-D Secure challenges. When the customer finishes, Nexi fires a server-to-server webhook and redirects the browser back to the merchant return URLs.

Two independent payment surfaces share the same gateway class:

  • Regular checkout (checkout module) — for standard shop orders stored in the orders table.
  • Gift card checkout (gift_cards module) — for gift card purchases stored in gift_card_orders.

Both environments (sandbox and production) are supported via a single boolean toggle. URLs:

  • Sandbox: https://xpaysandbox.nexigroup.com/api/phoenix-0.0/psp/ (src/PaymentGateways/NexiXPay/XPay.php:12)
  • Production: https://xpay.nexigroup.com/api/phoenix-0.0/psp/ (src/PaymentGateways/NexiXPay/XPay.php:11)

API Reference

HPP Order Creation

RequestPOST api/v1/orders/hpp

Headers:

HeaderValue
X-API-KEYMerchant API key from registry
Correlation-IdUUID v4 generated per request
Content-Typeapplication/json

Payload shape (src/PaymentGateways/NexiXPay/XPay.php:85-103):

order.orderId          → merchant order serial
order.amount           → cents (EUR×100)
order.currency         → 'EUR'
order.customerId       → optional
order.description      → optional
order.customerInfo.cardHolderEmail         → optional
order.customerInfo.mobilePhoneCountryCode  → optional
order.customerInfo.mobilePhone             → optional
paymentSession.actionType   → 'PAY'
paymentSession.recurrence.action → 'NO_RECURRING'
paymentSession.amount       → cents
paymentSession.language     → ISO 639-2 (default 'ELL' for Greek)
paymentSession.paymentService → 'cards'
paymentSession.resultUrl    → success redirect URL
paymentSession.cancelUrl    → cancel redirect URL
paymentSession.notificationUrl → server-to-server webhook URL

Success response (HTTP 200):

FieldDescription
hostedPageURL the browser must be redirected to
orderIdNexi-side order identifier (stored as tran_ticket)
securityTokenOpaque token echoed back in every webhook notification

If the response lacks hostedPage, the method returns null and logs an error (src/PaymentGateways/NexiXPay/XPay.php:156-158).

Order Status Query

RequestGET api/v1/orders/{xpayOrderId} (src/PaymentGateways/NexiXPay/XPay.php:351)

Returns the latest operation from the operations array (index 0). The caller uses operationResult to determine the current payment state.

operationResult Status Mapping

XPay::mapOperationResultToStatus() (src/PaymentGateways/NexiXPay/XPay.php:403-421):

Nexi operationResultInternal status
AUTHORIZED, EXECUTEDPAID
DECLINED, DENIED_BY_RISK, THREEDS_FAILED, VOIDED, FAILED, CANCELEDCANCELED
THREEDS_VALIDATED, PENDINGPENDING
REFUNDEDREFUNDED
anything elseUNKNOWN

Webhook Notification Shape

Nexi delivers a JSON body with two top-level keys — operation and order. Required fields:

  • operation.operationResult (string)
  • order.orderId (merchant order serial)
  • securityToken (root level — echoed from HPP creation response)

XPay::parsePaymentNotification() throws \InvalidArgumentException when operation, order, operationResult, or orderId is absent (src/PaymentGateways/NexiXPay/XPay.php:226-239).

Code Flow

Regular Checkout — Initiation

Entry: Adv_checkout::process_order() dispatches case 'xpay' to xpay() (ecommercen/checkout/controllers/Adv_checkout.php:156).

  1. Calls getXPaySettings() to load API key and environment flag (Adv_checkout.php:3422).
  2. Loads order and customer records; redirects to preview_order on lookup failure (Adv_checkout.php:3425-3440).
  3. Builds return URLs:
    • successUrlcheckout/get_response/xpay/success/{serial} (Adv_checkout.php:3445)
    • cancelUrlcheckout/get_response/xpay/cancel/{serial} (Adv_checkout.php:3446)
    • notificationUrlcheckout/xPayHook (Adv_checkout.php:3447)
  4. Splits customer phone via PhoneHelper::splitInternationalAndNational() (Adv_checkout.php:3450-3465).
  5. Calls XPay::createHostedPaymentOrder() with amount as $orderData->total_vat in EUR (Adv_checkout.php:3471-3479).
  6. On failure: logs REQUEST_FAILED, sets session error, redirects to preview_order (Adv_checkout.php:3482-3487).
  7. On success: logs REQUEST and redirects browser to $response['hostedPage'] (Adv_checkout.php:3491-3493).

Regular Checkout — Webhook (POST checkout/xPayHook)

Adv_checkout::xPayHook() (Adv_checkout.php:3502):

  1. Decodes input_stream() JSON; returns HTTP 400 on failure (Adv_checkout.php:3504-3511).
  2. Logs a CALLBACK row to xpay_logging via logXPayTransaction() before any verification (Adv_checkout.php:3515).
  3. Calls XPay::parsePaymentNotification(); returns HTTP 400 on \Exception (Adv_checkout.php:3517-3524).
  4. Calls XPay::verifyNotificationSecurityToken(); returns HTTP 401 on failure (Adv_checkout.php:3526-3533).
  5. Loads order; returns HTTP 404 if not found (Adv_checkout.php:3536-3542).
  6. Ignores REFUNDED notifications with an info log (Adv_checkout.php:3548-3554).
  7. State machine — updates when the transition is safe (Adv_checkout.php:3560-3574):
    • PENDING + PAID → update
    • PENDING + CANCELED → update
    • PAID + CANCELED → update (reversal)
    • CANCELED + PAID → update (resurrection)
  8. Delegates to processXPayPaymentResult() which calls order_model->set_status() / set_is_paid() / cancelOrder() and triggers post-hooks (email, SMS, stock alert, loyalty, ERP hook) (Adv_checkout.php:3722-3779).

Regular Checkout — Return URLs

xpayResponse() dispatches get_response/xpay/{action} to xPaySuccess() or xPayCancel() (Adv_checkout.php:3820-3832).

xPaySuccess() (Adv_checkout.php:3588):

  1. Reads order_serial from URI segment 5 and paymentid from query string.
  2. Updates tran_ticket on the order row if not already set (Adv_checkout.php:3601-3603).
  3. Calls XPay::getOrderStatus() with the serial; falls through to xPayCancel() on failure.
  4. Maps operationResult; if PAID, calls processXPayPaymentResult() then renders the success view (checkoutXPaySuccess).
  5. If not PAID, delegates to xPayCancel().

xPayCancel() (Adv_checkout.php:3675):

  1. Updates tran_ticket; cancels the order if still PENDING via cancelOrder().
  2. Renders the failure view (checkoutXPayFail).

Regular Checkout — Cron Reconciliation

AdvCancelIncompleteOrders::handlePendingXpayOrders() (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:190):

  1. Dispatched from the case 'xpay' branch of the per-order payway switch (AdvCancelIncompleteOrders.php:51).
  2. Calls XPay::getOrderStatus() with the order serial.
  3. If PAID, updates the order to PAID and fires the ERP hook (AdvCancelIncompleteOrders.php:202-203).
  4. Otherwise cancels the order, returns loyalty points, fires the cancel hook, and marks it order_debris (AdvCancelIncompleteOrders.php:196-199).

Gift Card Checkout — Initiation

AdvGiftCardPage::getPaywayFormData() dispatches case 'xpay' to xpayFormData() (ecommercen/gift_cards/controllers/AdvGiftCardPage.php:115).

xpayFormData() (AdvGiftCardPage.php:1118):

  1. Calls getXPaySettings() and builds a customer payload from gift_card_orders + phone splitting.
  2. Calls XPay::createHostedPaymentOrder() with the gift-card amount from $postData['giftCard'], the current store currency code, and the 'gift_card' flow discriminator (AdvGiftCardPage.php:1141-1150).
  3. Stores $response['orderId'] as tran_ticket on the gift card order row (AdvGiftCardPage.php:1159).
  4. Returns a ['type' => 'redirect', 'url' => $response['hostedPage']] envelope.

Gift Card Checkout — Webhook (POST gift-card/xPayHook)

AdvGiftCardPage::xPayHook() (AdvGiftCardPage.php:1218):

  1. Decodes input_stream() JSON; returns HTTP 400 on failure.
  2. Parses and verifies securityToken with flow 'gift_card' (AdvGiftCardPage.php:1239); returns HTTP 401 on failure.
  3. Ignores REFUNDED (AdvGiftCardPage.php:1256).
  4. Resolves GiftCardStatus enum from $orderObj->gift_card_status; logs error and returns on unknown value.
  5. Dispatches xpayWebhookAction() result:
    • 'accept'acceptGiftCard() + acceptPostActions() (issues coupon, marks Completed)
    • 'cancel'cancelGiftCard() + cancelPostActions()
    • 'noop' → logs and returns without modification

Gift Card — State Machine

AdvGiftCardPage::xpayWebhookAction(GiftCardStatus, string): string (AdvGiftCardPage.php:1309):

Current statusXPay statusAction
PendingPAIDaccept
PendingCANCELEDcancel
CompletedCANCELEDcancel (reversal)
CompletedPAIDnoop (idempotency guard)
Canceledanynoop (terminal)
anyPENDING / REFUNDED / unknownnoop

REFUNDED is filtered upstream in xPayHook() before xpayWebhookAction() is called (AdvGiftCardPage.php:1256).

Gift Card — Cron Reconciliation

AdvCancelPendingGiftCards::cancelPendingXpayOrders() (ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.php:91):

  • Queries pending gift card orders with payway='xpay' and created_at < now() - giftCardDateTimeIntervalToDrop.
  • Calls XPay::getOrderStatus() for each; accepts if PAID, cancels otherwise.
  • XPay orders are excluded from the bulk-cancel query that handles all other payways (AdvCancelPendingGiftCards.php:30).

Data Model

xpay_logging table

Created by database/migrations/20251117205429_create_xpay_logging_table.php.

ColumnTypeNotes
idunsigned int, auto-increment PK
order_serialVARCHAR(50) NOT NULLMerchant order serial
flowVARCHAR(20) NOT NULL'order' or 'gift_card'
transaction_idVARCHAR(100) NULLNexi-side order ID (populated on RESPONSE rows)
transaction_typeVARCHAR(50)REQUEST, RESPONSE, CALLBACK, PROCESSED, STATUS_CHECK, REQUEST_FAILED
request_dataTEXT NULLJSON-encoded request payload
response_dataTEXT NULLJSON-encoded response payload (contains securityToken on successful RESPONSE rows)
statusVARCHAR(50) NULLSUCCESS, FAILED, or null
created_atTIMESTAMP DEFAULT CURRENT_TIMESTAMP

Indexes (migration:20-31):

NameColumnsPurpose
idx_order_serialorder_serialOrder lookup
idx_transaction_idtransaction_idNexi-side ID lookup
idx_created_atcreated_atTime-range queries
idx_token_lookup(order_serial, flow, transaction_type, status, created_at)Covering index for getStoredSecurityToken() query

Write sources

xpay_logging is written from two separate code paths:

  1. XPay::logToDatabase() (src/PaymentGateways/NexiXPay/XPay.php:520) — writes REQUEST and RESPONSE rows (with flow and securityToken) during createHostedPaymentOrder().
  2. Adv_checkout::logXPayTransaction() (ecommercen/checkout/controllers/Adv_checkout.php:3790) — writes REQUEST, CALLBACK, PROCESSED, STATUS_CHECK, and REQUEST_FAILED rows but does not populate flow or transaction_id.

orders table (regular flow)

  • tran_ticket is updated on the success return URL with the paymentid query parameter (Adv_checkout.php:3601-3603).
  • status is set to PAID or CANCELED by processXPayPaymentResult().
  • is_paid flag is set to 1 on PAID (Adv_checkout.php:3740).

gift_card_orders table (gift card flow)

  • tran_ticket is written with the Nexi-side orderId at HPP creation time (AdvGiftCardPage.php:1159).
  • gift_card_status transitions via acceptGiftCard() / cancelGiftCard().

Domain Layer

Modern Layer

src/PaymentGateways/NexiXPay/XPay.php — PSR-4 class under Advisable\PaymentGateways\NexiXPay. Constructor accepts optional ?CI_DB_query_builder $db and ?Client $httpClient for testability; production callers use new XPay($config) and receive the autowired DI-container DB handle (XPay.php:37-54).

Public API:

MethodDescription
createHostedPaymentOrder(...)Creates an HPP order on the Nexi API; logs REQUEST and RESPONSE to xpay_logging
getOrderStatus(string $xpayOrderId)Polls GET api/v1/orders/{id}; returns parsed response or null on failure
parsePaymentNotification(array $notification)Validates and normalises a webhook payload; throws on missing required fields
verifyNotificationSecurityToken(array $notification, string $orderSerial, string $flow = 'order')Compares inbound securityToken against stored value using hash_equals; fail-closed
mapOperationResultToStatus(string $operationResult)Maps Nexi enum values to internal PAID / CANCELED / PENDING / REFUNDED / UNKNOWN
generateCorrelationId()Returns UUID v4 as required by the Nexi API header
formatAmount(float $amount, string $currency)Converts to integer cents via (int)round($amount * 100)

Legacy Layer

FileRole
ecommercen/checkout/controllers/Adv_checkout.phpRegular checkout — xpay(), xPayHook(), xPaySuccess(), xPayCancel(), xpayResponse(), processXPayPaymentResult(), logXPayTransaction()
ecommercen/gift_cards/controllers/AdvGiftCardPage.phpGift card checkout — xpayFormData(), xPayHook(), xPaySuccess(), xPayCancel(), xpayWebhookAction()
ecommercen/job/libraries/AdvCancelIncompleteOrders.phpRegular order cron reconciliation — handlePendingXpayOrders(), lazy xPay() accessor
ecommercen/gift_cards/jobs/AdvCancelPendingGiftCards.phpGift card cron reconciliation — cancelPendingXpayOrders()
ecommercen/helpers/registry_helper.phpgetXPaySettings() helper — reads XPAY.API_KEY and XPAY.IS_PRODUCTION from the registry (registry_helper.php:347-355)
ecommercen/eshop/libraries/AdvPaymentsRegistry.phpRegisters the xpay provider config with two settings: API_KEY (password type, required) and IS_PRODUCTION (checkbox, default false) (AdvPaymentsRegistry.php:697-706)

Configuration

All XPay settings are stored in the XPAY registry group and surfaced in the admin payment-settings view (ecommercen/settings/controllers/Adv_settings.php:1206-1207).

Registry KeyTypeRequiredDescription
XPAY.API_KEYpasswordYesMerchant API key issued by Nexi
XPAY.IS_PRODUCTIONbooleanNo (default false)false = sandbox, true = live production

getXPaySettings() reads both keys and returns an array consumed by new XPay($config) (ecommercen/helpers/registry_helper.php:347-355).

Gift card timeout — $config['giftCardDateTimeIntervalToDrop'] (PHP DateInterval string, default 'PT180M') controls when the AdvCancelPendingGiftCards cron drops unresolved pending XPay gift card orders (application/config/app.php:514).

Client Extension Points

The xpay case is handled inside the switch blocks of Adv_checkout::process_order() and Adv_checkout::get_response(). Client repos that override application/core/Front_c.php or create subclasses of Adv_checkout can:

  • Override processXPayPaymentResult() (marked protected) to add custom post-payment hooks (Adv_checkout.php:3722).
  • Override logXPayTransaction() (marked protected) to route audit rows to a different table or logging backend (Adv_checkout.php:3790).
  • Override AdvGiftCardPage::xpayWebhookAction() (marked public static) or replace the gift card controller entirely, since the state machine is a pure static helper.
  • Inject a custom \GuzzleHttp\Client into XPay to wrap HTTP calls with tenancy-level configuration (proxy, timeout, etc.).

Business Rules

  1. The Nexi XPay Greece HPP model transfers 3-D Secure and card-number responsibility to Nexi. The merchant never sees raw card data.
  2. Currency is hard-coded to 'EUR' in the regular checkout path (Adv_checkout.php:3474). The gift card path uses $this->currentCurrency->code (AdvGiftCardPage.php:1144).
  3. Amount must be expressed in integer cents. XPay::formatAmount() converts via (int)round($amount * 100) to avoid floating-point drift (XPay.php:430-434).
  4. The platform's only documented webhook-authenticity mechanism is the securityToken echo-back. There is no HMAC. verifyNotificationSecurityToken() is fail-closed: rejects when either the inbound token or the stored token is missing or empty (XPay.php:282-298).
  5. REFUNDED notifications are explicitly ignored on both webhook handlers — they log and return without touching order state (Adv_checkout.php:3548-3554, AdvGiftCardPage.php:1256).
  6. acceptGiftCard() is not idempotent: it always inserts a new coupon row. The xpayWebhookAction() state machine guards against re-issuing by returning 'noop' for (Completed, PAID) (AdvGiftCardPage.php:1314-1323).
  7. Cross-flow token collision is prevented by the flow discriminator column on xpay_logging. The idx_token_lookup covering index ensures the discriminated lookup is efficient (migration:23-31).
  8. 'xpay' is registered in getGiftCardPayWays() (ecommercen/helpers/eshop_helper.php:286-299) and in the gift-card Vue payWayInstallments map with an empty array (AdvGiftCardPage.php:811), matching the iris / alpha / ethniki / vivawallet pattern for payways that carry no installments.

Known Issues & Security Gaps

  1. Duplicate writes to xpay_logging on HPP creation. XPay::createHostedPaymentOrder() writes REQUEST and RESPONSE rows via logToDatabase() (XPay.php:131,145). The caller Adv_checkout::xpay() then writes a second REQUEST row (or REQUEST_FAILED) via logXPayTransaction() (Adv_checkout.php:3484,3491). A single HPP creation thus produces two REQUEST rows with different schemas (the gateway-level row includes flow, transaction_id, and full payload in request_data; the controller-level row lacks flow and stores the response envelope instead). This makes forensic queries on xpay_logging ambiguous for the HPP-creation event.

  2. logXPayTransaction() never writes the flow column. The controller-level helper (Adv_checkout.php:3790-3811) builds $logData without a flow key, so the column receives its default value. All rows written by CALLBACK, PROCESSED, STATUS_CHECK, and REQUEST_FAILED events have flow = NULL (or the column default). The idx_token_lookup index and the getStoredSecurityToken() query filter on flow, meaning these controller-level rows cannot serve as token sources and do not interfere with token lookup, but the schema contract is violated for audit purposes.

  3. xPayHook() does not stamp meta_data on the order. Other webhook handlers (JCC, Viva legacy) write an identifying string to shop_order.meta_data to record which code path caused the status change. xPayHook() and processXPayPaymentResult() omit this, making cross-provider forensic queries harder. The xpay_logging table provides a parallel audit trail but meta_data is inconsistent. (Adv_checkout.php:3502-3582)

  4. xPayCancel() does not guard against a missing paymentid query parameter. The guard at the top of xPayCancel() checks !$orderSerial || !$orderData || !$paymentId and calls inactive_payment() (Adv_checkout.php:3682-3685). However, Nexi's cancellation redirect may not include a paymentid parameter in all edge cases (browser back-button, session expiry). A customer returning without paymentid lands on inactive_payment() rather than a graceful "payment cancelled" page, with no order state update.

  5. xPaySuccess() makes an extra getOrderStatus() API call on every browser redirect. The HPP flow already delivers a server-to-server webhook before the browser redirect completes. If the webhook arrives first and updates the order to PAID, the getOrderStatus() call in xPaySuccess() is redundant (Adv_checkout.php:3606). In the reverse case (webhook delayed), the status check is necessary. There is no mechanism to short-circuit when the order is already PAID from the webhook, so every return-URL visit incurs an outbound API call.

  6. handlePendingXpayOrders() evaluates the null-check after the dependent map call. In AdvCancelIncompleteOrders::handlePendingXpayOrders(), $status = $this->xPay()->mapOperationResultToStatus($xpayOrderStatus['operationResult'] ?? '') is evaluated on line 193 even when $xpayOrderStatus is null. The null guard if (!$xpayOrderStatus || $status !== 'PAID') on line 195 catches this because the short-circuit || ensures the block is entered, but mapOperationResultToStatus('') returns 'UNKNOWN' — a different control flow path than an explicit null check would produce. The order is still cancelled correctly, but the intent is obscured. (ecommercen/job/libraries/AdvCancelIncompleteOrders.php:192-199)

  7. Regular checkout xpayResponse() does not return HTTP 200 to the Nexi webhook. xPayHook() sets explicit status codes for error paths (400, 401, 404) but does not explicitly set 200 for the success path. CodeIgniter's default response is 200, so this is not a runtime bug, but the lack of an explicit set_status_header(200) is inconsistent with the error paths and could cause a silent issue if the framework default changes. (Adv_checkout.php:3502-3582)

  8. No integration or end-to-end tests for the controller-layer XPay code paths. tests/Unit/PaymentGateways/NexiXPay/XPayTest.php covers the standalone XPay class (57 tests). tests/Legacy/GiftCards/AdvGiftCardPageTest.php covers the xpayWebhookAction() state machine only (12 tests). The controller methods xpay(), xPayHook(), xPaySuccess(), xPayCancel(), processXPayPaymentResult(), and the gift card xpayFormData(), xPayHook(), xPaySuccess(), xPayCancel() have no test coverage. The cron reconciliation helpers handlePendingXpayOrders() and cancelPendingXpayOrders() are also untested.

Tests

tests/Unit/PaymentGateways/NexiXPay/XPayTest.php — 57 tests

Runs in isolated processes (#[RunTestsInSeparateProcesses], #[PreserveGlobalState(false)]) because the XPay constructor reads config_item('ISO639-2') and config_item('language_abbr') which require a CI3 boot (XPayTest.php:40-41).

Coverage groups:

GroupDescription
ConstructorSandbox/production URL selection, IS_PRODUCTION truthy-string coercion, default-empty API key (XPayTest.php:85-139)
generateCorrelationIdUUID v4 validity and uniqueness across 10 consecutive calls (XPayTest.php:143-165)
mapOperationResultToStatusData-provider across 11 mapped values plus 2 UNKNOWN-fallthrough cases (13 data-provider rows total) including the UNKNOWN fallback (XPayTest.php:169-195)
parsePaymentNotificationHappy path (all fields), four InvalidArgumentException paths, optional-field nullability, rawNotification preservation, status map threading (XPayTest.php:197-343)
verifyNotificationSecurityToken10 tests: match, mismatch, missing token, empty token, non-string token, no stored row, empty response_data, missing securityToken key in stored row, DB exception, query-shape regression including flow filter and gift-card flow isolation (XPayTest.php:379-531)
HTTP-mocked createHostedPaymentOrderHappy path, missing hostedPage, ClientException, ServerException, ConnectException, endpoint assertion, cents conversion, header shape, optional customer data present/absent, language resolution from ISO 639-2 map (XPayTest.php:566-755)
HTTP-mocked getOrderStatusHappy path with multiple operations (latest = index 0), empty operations, correct endpoint, ClientException, ServerException (XPayTest.php:760-841)
API key redactionVerifies ***REDACTED*** appears and the raw key never appears in log output via a Monolog\TestHandler + DI container swap (XPayTest.php:845-886)

tests/Legacy/GiftCards/AdvGiftCardPageTest.php — 12 tests

Covers the pure static xpayWebhookAction() helper only.

TestDescription
Data-driven decision matrix10 (GiftCardStatus, xpayStatus) pairs verifying expected action string (AdvGiftCardPageTest.php:28-67)
Exhaustive 3×6 matrix walkAsserts only 'accept', 'cancel', 'noop' ever escape the function (AdvGiftCardPageTest.php:70-91)
Regression guardVerifies 'accept' is never returned outside the (Pending, PAID) path (AdvGiftCardPageTest.php:94-114)