Skip to content

Payment Webhooks & Callbacks ​

Flow ID: CF-09 | Module(s): webhooks, checkout, eshop, gift_cards | Complexity: High Last Updated: 2026-09-29


Business Context ​

Payment webhooks are asynchronous, server-to-server notifications sent by external payment providers to confirm (or reject) transactions. They serve as the authoritative payment confirmation for gateways that operate asynchronously, and as a fallback/supplement for gateways that also have a synchronous browser-redirect flow (get_response).

Legacy is the platform's sole payment-webhook layer — four ecommercen/webhooks/ handlers plus XPay in-controller handlers for provider-specific flows:

TypeProviderPurpose
Card paymentJCCAsynchronous notification after card authorization/deposit
BNPL / DeferredKlarna PaymentsAuthorization token delivery (payment not yet captured)
MarketplaceSkroutz SmartCartMarketplace order creation and state updates
Multi-methodViva WalletTransaction payment confirmation or failure, including gift cards
Card payment (in-controller)Nexi XPay GreeceAsynchronous notification after card authorization; co-hosted on Adv_checkout (orders) and AdvGiftCardPage (gift cards) — not in ecommercen/webhooks/

Retired REST layer (Advisable-com/ecommercen#721). A parallel REST-routed webhook family — src/Rest/Webhooks/Controllers/Webhook.php, five routes (POST /rest/webhooks/{stripe,vivawallet,paypal,piraeus,paybybank}) — was declared in 4.99.6 behind the APP_REST_API_ENABLED flag, became unconditionally reachable when 4.104.0 extracted it into its own route file, and has now been retired. Webhook::class never had a rest_policies.php entry, so every request inherited the global auth => backend default and 401'd for the entire span, from the day it first shipped in 4.99.6 through today; no gateway was ever configured to call any of the five URLs. Legacy has always been, and remains, the platform's only functioning payment-webhook surface. See the rest_api_versions.php entry and #721 for the full rationale, including the two known security defects (#620, #621) that made deleting the unreachable handler preferable to giving it a policy entry.

Key architectural difference from the synchronous flow (CF-08):

  • Webhooks are triggered by the payment server pushing to the shop. They are retryable by the provider and do not depend on the customer's browser session.
  • get_response (synchronous) is triggered by the customer's browser redirect back to the shop. It is a single-shot flow that also renders the thank-you page or error page for the customer.
  • For JCC and Viva Wallet, both flows can fire for the same order. The synchronous get_response checks whether the order status has already been updated by the webhook and acts accordingly (displaying the result without re-processing).

API Reference ​

Webhook URL Endpoints ​

Endpoint URL PatternRoute TargetHTTP MethodProvider
/jccNotificationwebhooks/jcc/handleNotificationPOSTJCC
/klarnaAuthorizationCallback/{secretToken}webhooks/KlarnaPayments/authorizationCallback/$1POSTKlarna
/handleSkroutzWebhookwebhooks/smartCart/handlePOSTSkroutz SmartCart
/webrun/skroutzWebhookRequestwebhooks/smartCart/handlePOSTSkroutz SmartCart (legacy alias)
/vivaWallet/{event}webhooks/viva/handleWebhook/$1POST (or GET for verification)Viva Wallet
/checkout/xPayHookcheckout/xPayHookPOSTNexi XPay (orders)
/{lang}/checkout/xPayHookcheckout/xPayHookPOSTNexi XPay (orders, localized)
/gift-card/xPayHookgift_cards/gift_card_page/xPayHook (via catch-all gift-card/(.+) at application/config/routes.php:733,735)POSTNexi XPay (gift cards)

Legacy handler routes are defined in application/config/routes.php (lines 545, 558-561). XPay regular-order routes are at routes.php:629-630; the gift-card route uses the catch-all at :733,735.

Viva Wallet Verification Endpoint ​

Viva Wallet uses a verification handshake before activating webhooks. When Viva sends a GET request to the webhook URL with an empty body, the handler responds with a verification key generated via the Viva API (VivaWallet::generateWebhookVerificationKey()). This calls Viva's /api/messages/config/token endpoint with Basic Auth (merchantId:apiKey).


Code Flow ​

Controller Inheritance ​

Each webhook follows the same two-tier class structure:

Base_c (application/core/)
  └─ Adv{Handler} (ecommercen/webhooks/) ← business logic
       └─ {Handler} (application/controllers/webhooks/) ← empty subclass, client-overridable

The application/controllers/webhooks/ files are thin subclasses (typically empty) that allow client repos to override behavior by extending the same class name in their own application/controllers/webhooks/ directory.

XPay structural exception: The XPay handler does not live in ecommercen/webhooks/. It is a public method on Adv_checkout (xPayHook, line 3906) for regular orders and AdvGiftCardPage (xPayHook, line 1218) for gift cards. Verification is delegated to the modern Advisable\PaymentGateways\NexiXPay\XPay service class — the only legacy webhook to use a src/-domain helper.


JCC Webhook (AdvJcc) ​

File: ecommercen/webhooks/AdvJcc.phpController: application/controllers/webhooks/Jcc.phpTrait: OrderForErpHookFireTrait

Step-by-Step Flow ​

  1. Receive POST at /jccNotification

    • Reads all POST parameters via $this->input->post()
    • If params are empty, logs error and exits
  2. Look up order

    • Queries jcc_order_ids table by mdOrder parameter to find the shop_order_id
    • Loads the full order from shop_order via order_model->getRecordsByJccOrderId()
    • If no order found, logs error with mdOrder and orderNumber, exits
  3. Validate HMAC-SHA256 checksum

    • Retrieves JCC.CALLBACK_TOKEN from the registry
    • Removes the checksum field from params
    • Sorts remaining params alphabetically by key (ksort)
    • Builds a parameter string in format: key1;value1;key2;value2;...
    • Computes HMAC-SHA256(paramString, callbackToken) and uppercases it
    • Compares against the received checksum value
  4. Calculate status

    • Only processes when operation is approved or deposited
    • If status param is '1' => order status = PAID; any other value => order status = CANCELED (ecommercen/webhooks/AdvJcc.php:65: ($params['status'] === '1') ? 'PAID' : 'CANCELED')
    • For other operation values, returns null (no action)
  5. Apply status update

    • If status is PAID: fires afterOrderSuccessHooks(orderId) which calls the ERP hook
    • Updates shop_order with status and meta_data = 'controller:webhooks/jcc'
    • Returns HTTP 200

Side Effects on PAID ​

  • internalApiOrderForErpHook($orderId) sends a GET request to the configured internal API ERP endpoint (internalApi.apiOrderWebHooks.erpReady/{orderId}) to notify the ERP system

Relationship with Synchronous Flow ​

The synchronous jccResponse() in Adv_checkout checks the order status first. If the order is no longer PENDING (because the webhook already processed it), it displays the appropriate success/failure message without re-processing. Only if the order is still PENDING does the synchronous flow query JCC's API for the order status.


Klarna Payments Webhook (AdvKlarnaPayments) ​

File: ecommercen/webhooks/AdvKlarnaPayments.phpController: application/controllers/webhooks/KlarnaPayments.php

Step-by-Step Flow ​

  1. Receive POST at /klarnaAuthorizationCallback/{secretToken}

    • Validates secretToken is not empty (returns 422 if missing)
    • Reads raw JSON body via $this->input->raw_input_stream
    • Returns 422 if body is empty
  2. Validate payload

    • Checks that authorization_token and session_id are both present
    • Logs individual errors for each missing field
    • Returns 422 if validation fails
  3. Update authorization token

    • Queries shop_order_klarna_payments table by session_id
    • If no matching record found, logs error and returns 422
    • Updates the authorization_token field on the matching record
    • Returns HTTP 200

Important: No Status Change ​

This webhook does not change order status. Klarna uses a decoupled flow:

  1. Customer authorizes payment on Klarna's side
  2. Klarna sends the authorization token to this webhook
  3. Later, during the get_response confirmation stage (klarnaPaymentsConfirmation), the shop uses the stored authorization token to capture the payment
  4. The checkout controller's getAuthorizationToken() method looks up the token from shop_order_klarna_payments if it was not passed directly in the checkout data
  5. The modern REST KlarnaAdapter likewise recovers the stored token from shop_order_klarna_payments via the Order\KlarnaPayment repository (recoverTokenFromCallback(), #307) when the front-end token is absent in paymentData

Skroutz SmartCart Webhook (AdvSmartCart) ​

File: ecommercen/webhooks/AdvSmartCart.phpController: application/controllers/webhooks/SmartCart.phpTrait: OrderForErpHookFireTraitModel: ecommercen/eshop/models/Adv_skroutz_orders_model.php

Step-by-Step Flow ​

  1. Receive POST at /handleSkroutzWebhook

    • Checks SMARTCART.IS_ENABLED registry value; returns 500 if disabled
    • Reads raw JSON input stream
  2. Route by event type

    new_order event:

    • Validates that order.state is open; returns 422 if not
    • Calls skroutz_orders_model->handleOrderCreation()
    • Checks for duplicate order by code
    • Inserts into skroutz_orders table (code, state, invoice, comments, courier details, customer data, pickup/collection point info, express flag)
    • If invoice is true, inserts into skroutz_invoice_details (company, VAT, DOY, address, VAT exclusion)
    • Inserts each line item into skroutz_line_items (product details, quantity, pricing, size, EAN)
    • Returns 200 on success, 500 on failure

    order_updated event:

    • Calls skroutz_orders_model->handleOrderUpdate()
    • Updates the existing skroutz_orders record with new state, pickup window, location, courier voucher, tracking codes
    • Returns 200 on success, 500 on failure
    • Then checks for express order auto-accept
  3. Express order auto-accept (post-update)

    • If order.express is true and order.state is accepted
    • Queries for a matching Skroutz order with shop_order_id IS NULL (not yet converted to a shop order)
    • Calls autoAcceptExpressOrder(code) which:
      • Creates a guest customer with a generated invalid email
      • Builds a fake cart from Skroutz line items with live product data
      • Creates a full shop order via order_model->create_order_admin()
      • Sets the shop order status to PAID with provider skroutz_smart_cart
      • Links the Skroutz order to the shop order (shop_order_id update)
    • Fires afterOrderSuccessHooks() (ERP notification)

Viva Wallet Webhook (AdvViva) ​

File: ecommercen/webhooks/AdvViva.phpController: application/controllers/webhooks/Viva.php

Step-by-Step Flow ​

  1. Receive request at /vivaWallet/{event}

    • Reads raw JSON body
    • Verification handshake: if the body is empty AND the request method is GET, returns the webhook verification key via $this->vivaWallet()->generateWebhookVerificationKey() (AdvViva.php:16-24); if the body is empty and the method is not GET, responds with HTTP 400 instead (AdvViva.php:19-21)
  2. Look up order (dual-path)

    • First searches shop_order by payway = 'vivawallet' and tran_ticket = EventData.OrderCode
    • If not found, flags as gift card and searches gift_card_orders by tran_ticket
    • If neither found, logs error and exits
  3. Gateway confirmation gate (AdvViva.php) — runs before either path below, on both the gift-card and regular-order branches

    • Events outside HANDLED_EVENTS (transactionPaymentCreated, transactionFailed) are acknowledged with HTTP 200 before Viva is consulted at all (:8, :53-57)
    • For a handled event, calls $this->vivaWallet()->vivaWalletValidateOrderIsPaid($transactionId) to verify the transaction against Viva's own record (:59-62)
    • If Viva is unreachable, responds with HTTP 502 so the notification is retried rather than lost (:66-74)
    • gatewayConfirms() (:153-168) must return true before any write happens: it cross-checks the orderCode via transactionBelongsToOrderCode() (:196-201), and for a success event also cross-checks the amount in integer minor units against order->amount (gift card) or order->total_vat (regular order) via amountConfirms() (:177-190) — the amount check is skipped (and logged) only when Viva reports the transaction as currency-converted
    • If gatewayConfirms() returns false, the handler logs and exits with HTTP 200 without writing anything
  4. Gift card path (if order found in gift_card_orders; only reached once the gateway confirmation gate above passes)

    EventAction
    transactionPaymentCreatedUpdates installments on the gift card order, then calls acceptGiftCard()
    transactionFailedCalls cancelGiftCard()
    Other eventsExits silently

    acceptGiftCard() details:

    • Creates a coupon with type GiftCard, discount equal to the gift card amount, max 1 usage, 5-year validity
    • Generates the coupon code
    • Updates gift card order: status -> Completed, sets completed_at, links coupon_id, resets email_sent and sms_sent flags
    • Wrapped in a DB transaction

    cancelGiftCard() details:

    • If status is Pending: bulk-cancels (sets gift_card_status = Canceled, canceled_at timestamp)
    • If status is Completed: deletes the associated coupon, nulls coupon_id, sets canceled status (wrapped in a DB transaction)

    Returns HTTP 200 and exits.

  5. Regular order path (only reached once the gateway confirmation gate above passes)

    • Idempotency guard: If order status is not PENDING, logs a warning and exits (prevents double-processing)
    • Sets meta_data = 'controller:WebhooksHandler:viva'
    EventStatus UpdateAdditional Fields
    transactionPaymentCreatedPAIDviva_payway (mapped from TransactionTypeId), installments
    transactionFailedCANCELEDNone
    Other eventsNo action (exits)N/A
    • Calls order_model->update_order() to persist the status change
    • Returns HTTP 200

Viva Payment Method Mapping ​

The getVivaPaymentMethodFromTransaction() helper (in ecommercen/helpers/eshop_helper.php) maps Viva's TransactionTypeId integers to internal payment method strings. Key mappings:

TransactionTypeIdInternal Method
0, 1, 4-8, 13, 18-19, 22, 31, 69vivawallet_credit_card
9, 11vivawallet_viva_wallet
15vivawallet_dias
16, 17vivawallet_cash
23, 24vivawallet_ideal
25, 26vivawallet_p24
48, 49vivawallet_paypal
52, 53, 80, 81vivawallet_klarna
60, 61vivawallet_iris
62, 63vivawallet_pay_by_bank
68vivawallet_pay_on_delivery

(Plus additional methods: BLIK, PayU, Giropay, SOFORT, EPS, WeChat Pay, BitPay, Trustly, MB Way, Multibanco, Payconiq, Bancomat Pay, TBI Bank, Swish, Bluecode.)

ecommercen/helpers/eshop_helper.php:304

Relationship with Synchronous Flow ​

The synchronous vivaWalletResponse() in Adv_checkout:

  • If order is still PENDING, queries Viva's API to validate payment
  • If order is already PAID (webhook arrived first), skips validation and renders the success page
  • The synchronous flow handles emails, SMS, stock updates, analytics, and session cleanup -- none of which the webhook handles

Important: The Viva Wallet webhook does not fire the ERP hook, send emails, or trigger any post-order side effects. This is by design -- the synchronous get_response flow handles those. If the synchronous flow arrives after the webhook, it detects the PAID status and still runs the full success pipeline.


XPay Webhook — Orders (Adv_checkout::xPayHook()) ​

File: ecommercen/checkout/controllers/Adv_checkout.php:3906-3986Gateway service: src/PaymentGateways/NexiXPay/XPay.php

Step-by-step flow:

  1. Reads raw JSON via $this->input->input_stream() — HTTP 400 on decode failure; audit-logs CALLBACK to xpay_logging via logXPayTransaction().
  2. XPay::parsePaymentNotification($notification) validates operation (required — real Greece S2S notifications carry no top-level order), operation.operationResult, and an order id in operation.orderId (falling back to top-level order.orderId only for the order-creation shape); HTTP 400 on InvalidArgumentException.
  3. XPay::verifyNotificationSecurityToken($notification, $orderId) (default flow='order') — HTTP 401 on mismatch (see securityToken section below).
  4. Order lookup via order_model->getOrder(['order_serial' => $orderId]) — HTTP 404 on miss.
  5. REFUNDED notifications: logged, exit HTTP 200 with no state change.
  6. State machine:
shop_order.statusXPay statusAction
PENDINGPAIDprocessXPayPaymentResult() → mark PAID
PENDINGCANCELEDprocessXPayPaymentResult() → cancel
PAIDCANCELEDprocessXPayPaymentResult() → reversal
CANCELEDPAIDprocessXPayPaymentResult() → resurrect
otheranylog, exit HTTP 200, no change

PAID side effects (via processXPayPaymentResult(), Adv_checkout.php:4141-4157): set_status('PAID') + set_is_paid() + afterOrderSuccessHooks() (ERP) + adv_mailer->order_complete() + sendSmsSuccess() + informLowStock(). CANCELED side effects: cancelOrder('xpay_callback') + afterOrderCancelHooks().

Note: unlike JCC/Viva, the XPay webhook fires customer-facing emails and SMS directly. See Known Issues.


XPay Webhook — Gift Cards (AdvGiftCardPage::xPayHook()) ​

File: ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1218-1290

Separate from checkout/xPayHook — lookup targets gift_card_orders, not orders. Uses flow='gift_card' to scope the securityToken lookup (cross-flow collision guard).

State machine via xpayWebhookAction(GiftCardStatus $current, string $xpayStatus): string:

Current gift_card_statusXPay statusAction
PendingPAIDacceptGiftCard() + acceptPostActions() (mint coupon, Completed)
PendingCANCELEDcancelGiftCard() + cancelPostActions()
CompletedCANCELEDcancelGiftCard() — reversal (revokes coupon)
otheranynoop (idempotency guard; acceptGiftCard() is not idempotent)

xpayWebhookAction() is public static, pure, unit-tested at tests/Legacy/GiftCards/AdvGiftCardPageTest.php:28-115.


XPay securityToken Verification ​

XPay Greece's only documented webhook-authentication mechanism is the securityToken echo-back (no HMAC, per developer.nexigroup.com/xpaygreece/en-EU/api/notification-api-v1/).

Token lifecycle:

  1. XPay::createHostedPaymentOrder() logs the Nexi HPP creation RESPONSE row to xpay_logging, which includes securityToken in response_data (src/PaymentGateways/NexiXPay/XPay.php:144).
  2. On every notification, Nexi echoes the same token in notification.securityToken.
  3. XPay::verifyNotificationSecurityToken() (:281) reads the stored token from xpay_logging filtered by (order_serial, flow, transaction_type='RESPONSE', status='SUCCESS') ordered by created_at DESC (:308-334), then compares with hash_equals().

Fail-closed on all error paths: missing token field, non-string value, empty string, no stored row, malformed JSON, DB exception — all return false (11 regression tests at XPayTest.php:380-509).

Cross-flow collision guard: the flow filter ('order' or 'gift_card') ensures a gift-card webhook cannot resolve a regular order's stored token even when GIFT_CARDS.ORDER_PREFIX is empty. Regression test at XPayTest.php:510-531.

Covering index: idx_token_lookup (order_serial, flow, transaction_type, status, created_at) on xpay_logging (database/migrations/20251117205429_create_xpay_logging_table.php:28-31).


Retired REST Webhook Handlers (Advisable-com/ecommercen#721) ​

Webhook::stripe(), vivawallet(), paypal(), piraeus(), and paybybank() — the five REST-routed handlers on the now-deleted src/Rest/Webhooks/Controllers/Webhook.php — are gone, along with their route file (application/config/webhook_routes.php), DI registration, and six-file unit test suite. See the retirement callout under Business Context above for why, and docs/decisions/721-retire-rest-webhooks.md for the full record. Two of the five gateways' payment adapters (PiraeusAdapter, PayByBankAdapter) had docblocks correcting their confirmation-mechanism citation from the deleted routes to the legacy checkout/get_response/{gateway} callback (Adv_checkout::piraeusResponse(), payByBankResponse()).


PaymentConfirmationService — No Longer Webhook-Driven ​

src/Domains/Checkout/PaymentConfirmationService.php (confirmPayment(), cancelPayment()) provided the idempotent order-status logic for the five retired REST webhook handlers. Since #754 confirmPayment() no longer sets shop_order.is_paid (PaymentConfirmationService.php:58-81), and the payment-status / confirm-payment responses dropped isPaid. It was not deleted — it has other consumers unaffected by the retirement: the JWT-authenticated POST /rest/checkout/confirm-payment/{orderId} client endpoint, Rest\Order\Controllers\Order, the KlarnaAdapter capture path, and the Jobs/PollPayByBankStatus polling job. None of these is a webhook, so the service and its OrderPaid/OrderCanceled event-bus dispatch are out of scope for this doc — see CF-06 Order Preview for the confirm-payment endpoint and the full OrderPaid listener chain.


Domain Layer ​

Modern Domain (src/Domains/...) ​

Modern-domain files that served the retired REST webhook family are out of scope here (PaymentConfirmationService and StockService — see the note above; and Repository::findBySerialAndPayway()/findByTranTicketAndPayway(), which now have no caller anywhere in the codebase — their only consumer was the deleted controller). The one modern-domain file still load-bearing for a live webhook:

FileResponsibility
src/PaymentGateways/NexiXPay/XPay.phpNexi XPay Greece gateway service: HPP creation, order status query, notification parsing, securityToken verification (shared by both the orders and gift-card webhook paths)

Legacy Layer (ecommercen/...) ​

Class Diagram ​

application/controllers/webhooks/
  Jcc.php              ─extends─> AdvJcc              (ecommercen/webhooks/)
  KlarnaPayments.php   ─extends─> AdvKlarnaPayments   (ecommercen/webhooks/)
  SmartCart.php         ─extends─> AdvSmartCart         (ecommercen/webhooks/)
  Viva.php             ─extends─> AdvViva              (ecommercen/webhooks/)
FileResponsibility
ecommercen/webhooks/AdvJcc.phpJCC card payment notification handler; HMAC-SHA256 checksum validation
ecommercen/webhooks/AdvKlarnaPayments.phpKlarna authorization token delivery; stores token in shop_order_klarna_payments
ecommercen/webhooks/AdvSmartCart.phpSkroutz SmartCart order creation, update, and express auto-accept
ecommercen/webhooks/AdvViva.phpViva Wallet payment confirmation/failure; dual lookup for gift card orders
ecommercen/checkout/controllers/Adv_checkout.php (xPayHook, processXPayPaymentResult, logXPayTransaction)XPay webhook for regular orders; co-hosted on checkout controller
ecommercen/gift_cards/controllers/AdvGiftCardPage.php (xPayHook, xpayWebhookAction)XPay webhook for gift cards; co-hosted on gift-card controller
application/controllers/webhooks/Jcc.phpThin subclass; client-overridable entry point
application/controllers/webhooks/KlarnaPayments.phpThin subclass; client-overridable entry point
application/controllers/webhooks/SmartCart.phpThin subclass; client-overridable entry point
application/controllers/webhooks/Viva.phpThin subclass; client-overridable entry point

All upstream handler classes extend Base_c (which extends Adv_base_controller). This gives them access to:

  • $this->registry (DB-backed configuration)
  • $this->input (CI input class with post(), raw_input_stream, inputStream())
  • $this->load->model() (model loading)
  • $this->output->set_header() (HTTP response headers)

ERP Hook Chain ​

OrderForErpHookFireTrait::internalApiOrderForErpHook($orderId)
  └─ InternalApiOrderForErpHook::fireHook($orderId)     [application/libraries/internal/]
       └─ AdvInternalApiOrderForErpHook                   [ecommercen/libraries/internal/]
            ├─ FireHookTrait::fireHook()
            │    ├─ IsApiEnabledTrait::isApiEnabled()      ← checks config('internalApi.useApi')
            │    ├─ getUrl($orderId)                       ← builds URL from config('internalApi.apiOrderWebHooks.erpReady')
            │    └─ call($url, $orderId)                   ← Guzzle GET with Bearer token + timeout
            └─ HookGetClientOptionsTrait
                 ├─ getClient()                            ← Guzzle client with Authorization, Accept, Content-Type headers
                 └─ defaultRequestOptions()                ← connect_timeout and timeout from config

The ERP hook is only fired by JCC and SmartCart webhooks via this trait. Viva Wallet and Klarna webhooks do not fire it. XPay also fires the ERP hook (via afterOrderSuccessHooks() in Adv_checkout::processXPayPaymentResult()).


Data Model ​

Database Tables Affected ​

TableHandlerOperationPurpose
shop_orderJCC, VivaUPDATEStatus change (PAID/CANCELED), meta_data, viva_payway, installments
shop_orderSmartCart (auto-accept)INSERT + UPDATECreates new shop order, sets status to PAID
jcc_order_idsJCCREADMaps JCC mdOrder to shop_order_id
shop_order_klarna_paymentsKlarnaREAD + UPDATEStores/updates authorization_token by session_id
skroutz_ordersSmartCartINSERT + UPDATEStores marketplace order data
skroutz_line_itemsSmartCartINSERTStores order line items
skroutz_invoice_detailsSmartCartINSERTStores invoice data for business orders
gift_card_ordersViva (gift card)READ + UPDATEStatus changes, installment updates
couponsViva (gift card accept)INSERTCreates gift card coupon
couponsViva (gift card cancel)DELETERemoves coupon if gift card was completed
product_codesSmartCart (auto-accept)UPDATEStock decrement via create_order_admin
shop_customerSmartCart (auto-accept)INSERTCreates guest customer for marketplace order
shop_order_basketSmartCart (auto-accept)INSERTOrder line items for the shop order
xpay_loggingXPay (both flows)INSERT + READAudit trail and securityToken lookup source
gift_card_ordersXPay (gift cards)UPDATEStatus (Completed/Canceled), coupon_id

Order Status Values ​

StatusMeaningLayer
PENDINGOrder created, awaiting payment confirmationAll (legacy webhook + synchronous)
PAIDPayment confirmed successfullyAll
CANCELEDPayment failed or was rejectedAll

Gift Card Status Values (Spatie Enum) ​

StatusValueMeaning
Pending1Awaiting payment
Completed10Payment confirmed, coupon created
Canceled11Payment failed or coupon deleted

meta_data Field Tracing ​

The shop_order.meta_data field tracks which code path last modified the order:

ValueSource
controller:webhooks/jccJCC webhook
controller:WebhooksHandler:vivaViva Wallet legacy webhook
model:set_status:CANCELED:update_orderupdate_order() when canceling (JCC/Viva legacy)
model:set_is_paidset_is_paid() in synchronous flow

Historical only — orders placed before Advisable-com/ecommercen#721 may still carry a webhook:stripe, webhook:vivawallet (or :failed), webhook:paypal:{eventType}, webhook:piraeus (or :failed), or webhook:paybybank (or :cancelled / :ready_to_cancel) value, written by the now-deleted REST webhook controller. No live code path writes any webhook:* value today.


Configuration ​

Registry Keys ​

GroupKeyUsed ByPurpose
JCCCALLBACK_TOKENAdvJccHMAC-SHA256 secret for checksum validation
SMARTCARTIS_ENABLEDAdvSmartCartFeature flag to enable/disable SmartCart webhooks
XPAYAPI_KEYXPay gateway serviceX-API-KEY header for Nexi XPay Greece API calls
XPAYIS_PRODUCTIONXPay gateway serviceToggles between sandbox and production base URL

STRIPE.WEBHOOK_SECRET and a Piraeus-keys-via-PiraeusConfig::fromArray() row previously appeared here — both were consumed only by the retired REST webhook handler. STRIPE.WEBHOOK_SECRET now has no reader anywhere in the codebase; Piraeus's config resolution for payment initialization (CF-08) uses PaymentInitializerFactory, not this doc's webhook confirmation path.

Application Config ​

Config PathUsed ByPurpose
internalApi.useApiERP hookMaster switch for internal API hooks
internalApi.apiOrderWebHooks.erpReadyERP hookBase URL for ERP notification endpoint
internalApi.apiTokenERP hookBearer token for internal API auth
internalApi.clientConnectTimeoutERP hookGuzzle connection timeout (seconds)

Viva Wallet Config (via Factories::vivaWallet()) ​

Registry/ConfigPurpose
Viva merchantIdBasic Auth username for webhook verification
Viva apiKeyBasic Auth password for webhook verification
Viva isProductionDetermines verification URL (production vs demo)

Client Extension Points ​

Overriding Webhook Handlers ​

Client repos can override any webhook handler by creating a class with the same name in application/controllers/webhooks/:

php
// application/controllers/webhooks/Jcc.php (client repo)
class Jcc extends AdvJcc
{
    protected function afterOrderSuccessHooks(int $orderId): void
    {
        parent::afterOrderSuccessHooks($orderId);
        // Client-specific logic (e.g., custom ERP integration, notifications)
    }
}

Key Override Points per Handler ​

HandlerOverridable MethodsPurpose
AdvJccafterOrderSuccessHooks(), validateChecksum(), calculateStatus()Custom ERP hooks, alternative validation, custom status logic
AdvKlarnaPaymentscheckPayload(), updateShopOrderKlarnaPayment()Additional validation, custom storage logic
AdvSmartCartafterOrderSuccessHooks(), checkAndAutoAcceptExpressOrder()Custom ERP hooks, custom auto-accept logic
AdvVivahandleWebhook() (entire method)Full custom handling
Adv_checkout (xPayHook)processXPayPaymentResult(), logXPayTransaction() — both protectedCustom side effects on PAID/CANCELED, custom audit logging
AdvGiftCardPage (xPayHook)acceptGiftCard(), cancelGiftCard(), acceptPostActions(), cancelPostActions() — all protectedCustomize coupon issuance/revocation, post-acceptance side effects

Adding New Webhook Handlers ​

To add a new payment webhook in a client repo:

  1. Create the handler class in ecommercen/webhooks/Adv{Provider}.php (main repo) or directly in application/controllers/webhooks/{Provider}.php (client repo)
  2. Add a route in application/config/routes.php
  3. If the handler needs to fire the ERP hook, use OrderForErpHookFireTrait

Business Rules ​

RuleDescriptionHandler(s)
Legacy PENDING guardLegacy Viva/JCC handlers only process orders in PENDING status and exit if the order is in any other stateViva (legacy), JCC
Order existence checkAll handlers verify the order exists before processingAll
HMAC validationJCC validates callback authenticity via HMAC-SHA256 checksumJCC
Feature flagSmartCart requires SMARTCART.IS_ENABLED registry flagSmartCart
ERP notificationERP hook fires on successful payment (only if internalApi.useApi is enabled)JCC, SmartCart (direct); XPay via afterOrderSuccessHooks()
No emails from legacy webhooksLegacy webhook handlers do not send confirmation emails -- the synchronous flow handles thisJCC, Viva (XPay is an exception — see Known Issues)
Klarna is decoupledOnly stores authorization token; payment capture happens at confirmation stageKlarna
Express auto-acceptSkroutz express orders are automatically converted to shop orders when state becomes acceptedSmartCart
Gift card dual lookupViva webhook checks both shop_order and gift_card_orders tablesViva
Gift card coupon lifecycleAccept creates a coupon (5-year validity, 1 use); cancel deletes the coupon if already createdViva
Webhook-first for JCCJCC synchronous flow defers to webhook result if order is no longer PENDINGJCC
Viva pre-auth handlingSynchronous Viva flow detects when webhook already confirmed payment and renders success without reprocessingViva
Stock management (legacy cancel)Legacy cancel via update_order triggers set_status('CANCELED') which calls returnOrderStock() to restore stockJCC, Viva (legacy)
Meta data tracingEach handler writes its identity into shop_order.meta_data for audit trailJCC, Viva
XPay state machine (orders)PENDING+PAID, PENDING+CANCELED, PAID+CANCELED (reversal), CANCELED+PAID (resurrect) — other combinations are noop (Adv_checkout.php:3964-3979)XPay (orders)
XPay state machine (gift cards)Pending+PAID→accept; Pending/Completed+CANCELED→cancel; all others noop to prevent duplicate coupon minting (AdvGiftCardPage.php:1309-1324)XPay (gift cards)
XPay REFUND filterREFUNDED notifications dropped silently by both handlersXPay
XPay sends emails/SMS from webhookUnlike JCC/Viva, the XPay webhook fires adv_mailer->order_complete(), sendSmsSuccess(), and informLowStock() on PAIDXPay (orders)

Webhook vs. Synchronous Side Effects Comparison ​

The table below applies to the legacy webhook layer — the platform's only payment-webhook layer since Advisable-com/ecommercen#721 retired the REST family.

Side EffectLegacy WebhookSynchronous (get_response)
Update order statusYesYes
Fire ERP hookJCC + SmartCart only (XPay also fires it via afterOrderSuccessHooks())Yes (all gateways)
Send confirmation emailNo (except XPay orders webhook — see Known Issues)Yes
Send confirmation SMSNo (except XPay orders webhook — see Known Issues)Yes
Low stock notificationNo (except XPay orders webhook — see Known Issues)Yes
Analytics reporting (Matomo, Meta, Manago)NoYes
Destroy cart sessionNoYes
Guest customer logoutNoYes
Render thank-you pageNoYes
Set is_paid = 1Legacy gateway handlers (incl. the XPay orders webhook, set_is_paid(), Adv_checkout.php:4141-4157); the legacy synchronous path also sets it via set_is_paid() (Adv_order_model.php:1916-1918). REST PaymentConfirmationService::confirmPayment() no longer writes is_paid (#754, PaymentConfirmationService.php:58-81; the comment at :60-76 lists the legacy set_is_paid() sites)Yes
Reserve stock at placementAll payways — reduceStockForOrder() at PlaceOrderService.php:770 (#282)All payways — legacy create_order / POS
Restore stock on cancelJCC/Viva via set_status('CANCELED', stockMode='+'); XPay via cancelOrder('xpay_callback') ('+' direction)set_status('CANCELED', stockMode='+')

Known Issues & Security Gaps ​

Items formerly numbered here for the REST webhook handlers (signature verification, gift-card support, unmatched-order handling, duplicate event dispatch, etc.) are removed: that controller and its PaymentConfirmationService event-bus consumers are retired or out of scope for this doc (Advisable-com/ecommercen#721 — see the callout under Business Context, and docs/decisions/721-retire-rest-webhooks.md for the resolution history of each).

  1. XPay webhook does not stamp shop_order.meta_data: Other webhooks (JCC, Viva legacy) write meta_data identifying the code path. xPayHook() and processXPayPaymentResult() omit this. The xpay_logging table provides a parallel audit trail but meta_data inconsistency makes cross-provider forensics harder. (ecommercen/checkout/controllers/Adv_checkout.php:3906-3986)

  2. XPay PAID→CANCELED reversal stock behavior: cancelOrder() is called with '+' (positive) stock direction arg, restoring stock. Verify whether set_status('PAID') previously decremented stock; if not, this reversal may double-credit. (ecommercen/checkout/controllers/Adv_checkout.php:4159-4171)

  3. XPay CANCELED→PAID resurrection re-sends customer emails/SMS: The PAID branch of processXPayPaymentResult() always fires adv_mailer->order_complete() + sendSmsSuccess(), with no guard for prior sends. (ecommercen/checkout/controllers/Adv_checkout.php:4141-4157)

  4. XPay webhook fires emails/SMS — diverges from JCC/Viva contract: Both the webhook and xPaySuccess() synchronous path may run these side effects. If both fire, customer may receive duplicate confirmation email/SMS. (ecommercen/checkout/controllers/Adv_checkout.php:3906-3986)

  5. xPaySuccess() writes tran_ticket from customer-controlled querystring: reads ?paymentid= before API verification completes; spoof damages the tran_ticket audit field only (downstream getOrderStatus is by order_serial), but may affect admin views.

  6. PayByBank legacy callback has no authentication RESOLVED (Advisable-com/ecommercen#619, #780) — Adv_checkout::payByBankResponse() (ecommercen/checkout/controllers/Adv_checkout.php:1062-1133) now scopes the order lookup by payway='paybybank' and requires a live PayByBank gateway confirmation (payByBankReportsPaid()/payByBankReportsCancelled(), :1150-1179) before moving the order to PAID or CANCELED; every exit path answers identically via acknowledgePayByBankCallback() (:1144-1148), removing the serial-enumeration oracle. See docs/decisions/619-paybybank-forged-callback.md and docs/decisions/780-paybybank-cancel-arm-gate.md.


Tests ​

Test FileWhat It Covers
tests/Unit/PaymentGateways/NexiXPay/XPayTest.php45 tests on gateway service: parsePaymentNotification, mapOperationResultToStatus, verifyNotificationSecurityToken (11 cases incl. cross-flow guard), HTTP-mocked HPP creation and order status, API-key redaction. Does NOT cover the controller-level xPayHook() — only the gateway helper.
tests/Legacy/GiftCards/AdvGiftCardPageTest.php12 tests on xpayWebhookAction state machine
tests/Legacy/Webhooks/AdvVivaTest.php + AdvSmartCartTest.php38 combined tests: handleWebhook/handle dispatch, payload validation, status transitions via reflection + mocked models

No unit tests exist for the legacy webhook controllers (AdvJcc, AdvKlarnaPayments, AdvSmartCart, AdvViva). RESOLVED (commit 4fd81ed1e, Advisable-com/ecommercen#34) — see AdvVivaTest/AdvSmartCartTest above. (The REST Webhook controller's own six-file test suite, added by the same effort for Stripe/Viva/PayPal/Piraeus/PayByBank, was removed along with the controller in Advisable-com/ecommercen#721.) The controller-level XPay webhook flow (Adv_checkout::xPayHook()) also has no controller test; only the underlying XPay gateway service and the gift-card state machine are directly unit-tested.