Appearance
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:
| Type | Provider | Purpose |
|---|---|---|
| Card payment | JCC | Asynchronous notification after card authorization/deposit |
| BNPL / Deferred | Klarna Payments | Authorization token delivery (payment not yet captured) |
| Marketplace | Skroutz SmartCart | Marketplace order creation and state updates |
| Multi-method | Viva Wallet | Transaction payment confirmation or failure, including gift cards |
| Card payment (in-controller) | Nexi XPay Greece | Asynchronous 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 theAPP_REST_API_ENABLEDflag, became unconditionally reachable when 4.104.0 extracted it into its own route file, and has now been retired.Webhook::classnever had arest_policies.phpentry, so every request inherited the globalauth => backenddefault 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 therest_api_versions.phpentry 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_responsechecks 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 Pattern | Route Target | HTTP Method | Provider |
|---|---|---|---|
/jccNotification | webhooks/jcc/handleNotification | POST | JCC |
/klarnaAuthorizationCallback/{secretToken} | webhooks/KlarnaPayments/authorizationCallback/$1 | POST | Klarna |
/handleSkroutzWebhook | webhooks/smartCart/handle | POST | Skroutz SmartCart |
/webrun/skroutzWebhookRequest | webhooks/smartCart/handle | POST | Skroutz SmartCart (legacy alias) |
/vivaWallet/{event} | webhooks/viva/handleWebhook/$1 | POST (or GET for verification) | Viva Wallet |
/checkout/xPayHook | checkout/xPayHook | POST | Nexi XPay (orders) |
/{lang}/checkout/xPayHook | checkout/xPayHook | POST | Nexi XPay (orders, localized) |
/gift-card/xPayHook | gift_cards/gift_card_page/xPayHook (via catch-all gift-card/(.+) at application/config/routes.php:733,735) | POST | Nexi 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-overridableThe 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 onAdv_checkout(xPayHook, line 3906) for regular orders andAdvGiftCardPage(xPayHook, line 1218) for gift cards. Verification is delegated to the modernAdvisable\PaymentGateways\NexiXPay\XPayservice class — the only legacy webhook to use asrc/-domain helper.
JCC Webhook (AdvJcc)
File: ecommercen/webhooks/AdvJcc.phpController: application/controllers/webhooks/Jcc.phpTrait: OrderForErpHookFireTrait
Step-by-Step Flow
Receive POST at
/jccNotification- Reads all POST parameters via
$this->input->post() - If params are empty, logs error and exits
- Reads all POST parameters via
Look up order
- Queries
jcc_order_idstable bymdOrderparameter to find theshop_order_id - Loads the full order from
shop_orderviaorder_model->getRecordsByJccOrderId() - If no order found, logs error with
mdOrderandorderNumber, exits
- Queries
Validate HMAC-SHA256 checksum
- Retrieves
JCC.CALLBACK_TOKENfrom the registry - Removes the
checksumfield 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
checksumvalue
- Retrieves
Calculate status
- Only processes when
operationisapprovedordeposited - If
statusparam is'1'=> order status =PAID; any other value => order status =CANCELED(ecommercen/webhooks/AdvJcc.php:65:($params['status'] === '1') ? 'PAID' : 'CANCELED') - For other
operationvalues, returnsnull(no action)
- Only processes when
Apply status update
- If status is
PAID: firesafterOrderSuccessHooks(orderId)which calls the ERP hook - Updates
shop_orderwithstatusandmeta_data = 'controller:webhooks/jcc' - Returns HTTP 200
- If status is
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
Receive POST at
/klarnaAuthorizationCallback/{secretToken}- Validates
secretTokenis not empty (returns 422 if missing) - Reads raw JSON body via
$this->input->raw_input_stream - Returns 422 if body is empty
- Validates
Validate payload
- Checks that
authorization_tokenandsession_idare both present - Logs individual errors for each missing field
- Returns 422 if validation fails
- Checks that
Update authorization token
- Queries
shop_order_klarna_paymentstable bysession_id - If no matching record found, logs error and returns 422
- Updates the
authorization_tokenfield on the matching record - Returns HTTP 200
- Queries
Important: No Status Change
This webhook does not change order status. Klarna uses a decoupled flow:
- Customer authorizes payment on Klarna's side
- Klarna sends the authorization token to this webhook
- Later, during the
get_responseconfirmation stage (klarnaPaymentsConfirmation), the shop uses the stored authorization token to capture the payment - The checkout controller's
getAuthorizationToken()method looks up the token fromshop_order_klarna_paymentsif it was not passed directly in the checkout data - The modern REST
KlarnaAdapterlikewise recovers the stored token fromshop_order_klarna_paymentsvia theOrder\KlarnaPaymentrepository (recoverTokenFromCallback(), #307) when the front-end token is absent inpaymentData
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
Receive POST at
/handleSkroutzWebhook- Checks
SMARTCART.IS_ENABLEDregistry value; returns 500 if disabled - Reads raw JSON input stream
- Checks
Route by event type
new_orderevent:- Validates that
order.stateisopen; returns 422 if not - Calls
skroutz_orders_model->handleOrderCreation() - Checks for duplicate order by
code - Inserts into
skroutz_orderstable (code, state, invoice, comments, courier details, customer data, pickup/collection point info, express flag) - If
invoiceis true, inserts intoskroutz_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_updatedevent:- Calls
skroutz_orders_model->handleOrderUpdate() - Updates the existing
skroutz_ordersrecord with new state, pickup window, location, courier voucher, tracking codes - Returns 200 on success, 500 on failure
- Then checks for express order auto-accept
- Validates that
Express order auto-accept (post-update)
- If
order.expressistrueandorder.stateisaccepted - 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
PAIDwith providerskroutz_smart_cart - Links the Skroutz order to the shop order (
shop_order_idupdate)
- Fires
afterOrderSuccessHooks()(ERP notification)
- If
Viva Wallet Webhook (AdvViva)
File: ecommercen/webhooks/AdvViva.phpController: application/controllers/webhooks/Viva.php
Step-by-Step Flow
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)
Look up order (dual-path)
- First searches
shop_orderbypayway = 'vivawallet'andtran_ticket = EventData.OrderCode - If not found, flags as gift card and searches
gift_card_ordersbytran_ticket - If neither found, logs error and exits
- First searches
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 returntruebefore any write happens: it cross-checks the orderCode viatransactionBelongsToOrderCode()(:196-201), and for a success event also cross-checks the amount in integer minor units againstorder->amount(gift card) ororder->total_vat(regular order) viaamountConfirms()(:177-190) — the amount check is skipped (and logged) only when Viva reports the transaction as currency-converted- If
gatewayConfirms()returnsfalse, the handler logs and exits with HTTP 200 without writing anything
- Events outside
Gift card path (if order found in
gift_card_orders; only reached once the gateway confirmation gate above passes)Event Action transactionPaymentCreatedUpdates installments on the gift card order, then calls acceptGiftCard()transactionFailedCalls cancelGiftCard()Other events Exits 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, setscompleted_at, linkscoupon_id, resetsemail_sentandsms_sentflags - Wrapped in a DB transaction
cancelGiftCard()details:- If status is
Pending: bulk-cancels (setsgift_card_status = Canceled,canceled_attimestamp) - If status is
Completed: deletes the associated coupon, nullscoupon_id, sets canceled status (wrapped in a DB transaction)
Returns HTTP 200 and exits.
- Creates a coupon with type
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'
Event Status Update Additional Fields transactionPaymentCreatedPAIDviva_payway(mapped fromTransactionTypeId),installmentstransactionFailedCANCELEDNone Other events No action (exits) N/A - Calls
order_model->update_order()to persist the status change - Returns HTTP 200
- Idempotency guard: If order status is not
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:
| TransactionTypeId | Internal Method |
|---|---|
| 0, 1, 4-8, 13, 18-19, 22, 31, 69 | vivawallet_credit_card |
| 9, 11 | vivawallet_viva_wallet |
| 15 | vivawallet_dias |
| 16, 17 | vivawallet_cash |
| 23, 24 | vivawallet_ideal |
| 25, 26 | vivawallet_p24 |
| 48, 49 | vivawallet_paypal |
| 52, 53, 80, 81 | vivawallet_klarna |
| 60, 61 | vivawallet_iris |
| 62, 63 | vivawallet_pay_by_bank |
| 68 | vivawallet_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:
- Reads raw JSON via
$this->input->input_stream()— HTTP 400 on decode failure; audit-logsCALLBACKtoxpay_loggingvialogXPayTransaction(). XPay::parsePaymentNotification($notification)validatesoperation(required — real Greece S2S notifications carry no top-levelorder),operation.operationResult, and an order id inoperation.orderId(falling back to top-levelorder.orderIdonly for the order-creation shape); HTTP 400 onInvalidArgumentException.XPay::verifyNotificationSecurityToken($notification, $orderId)(defaultflow='order') — HTTP 401 on mismatch (see securityToken section below).- Order lookup via
order_model->getOrder(['order_serial' => $orderId])— HTTP 404 on miss. - REFUNDED notifications: logged, exit HTTP 200 with no state change.
- State machine:
shop_order.status | XPay status | Action |
|---|---|---|
| PENDING | PAID | processXPayPaymentResult() → mark PAID |
| PENDING | CANCELED | processXPayPaymentResult() → cancel |
| PAID | CANCELED | processXPayPaymentResult() → reversal |
| CANCELED | PAID | processXPayPaymentResult() → resurrect |
| other | any | log, 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_status | XPay status | Action |
|---|---|---|
| Pending | PAID | acceptGiftCard() + acceptPostActions() (mint coupon, Completed) |
| Pending | CANCELED | cancelGiftCard() + cancelPostActions() |
| Completed | CANCELED | cancelGiftCard() — reversal (revokes coupon) |
| other | any | noop (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:
XPay::createHostedPaymentOrder()logs the Nexi HPP creation RESPONSE row toxpay_logging, which includessecurityTokeninresponse_data(src/PaymentGateways/NexiXPay/XPay.php:144).- On every notification, Nexi echoes the same token in
notification.securityToken. XPay::verifyNotificationSecurityToken()(:281) reads the stored token fromxpay_loggingfiltered by(order_serial, flow, transaction_type='RESPONSE', status='SUCCESS')ordered bycreated_at DESC(:308-334), then compares withhash_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:
| File | Responsibility |
|---|---|
src/PaymentGateways/NexiXPay/XPay.php | Nexi 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/)| File | Responsibility |
|---|---|
ecommercen/webhooks/AdvJcc.php | JCC card payment notification handler; HMAC-SHA256 checksum validation |
ecommercen/webhooks/AdvKlarnaPayments.php | Klarna authorization token delivery; stores token in shop_order_klarna_payments |
ecommercen/webhooks/AdvSmartCart.php | Skroutz SmartCart order creation, update, and express auto-accept |
ecommercen/webhooks/AdvViva.php | Viva 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.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/KlarnaPayments.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/SmartCart.php | Thin subclass; client-overridable entry point |
application/controllers/webhooks/Viva.php | Thin 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 withpost(),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 configThe 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
| Table | Handler | Operation | Purpose |
|---|---|---|---|
shop_order | JCC, Viva | UPDATE | Status change (PAID/CANCELED), meta_data, viva_payway, installments |
shop_order | SmartCart (auto-accept) | INSERT + UPDATE | Creates new shop order, sets status to PAID |
jcc_order_ids | JCC | READ | Maps JCC mdOrder to shop_order_id |
shop_order_klarna_payments | Klarna | READ + UPDATE | Stores/updates authorization_token by session_id |
skroutz_orders | SmartCart | INSERT + UPDATE | Stores marketplace order data |
skroutz_line_items | SmartCart | INSERT | Stores order line items |
skroutz_invoice_details | SmartCart | INSERT | Stores invoice data for business orders |
gift_card_orders | Viva (gift card) | READ + UPDATE | Status changes, installment updates |
coupons | Viva (gift card accept) | INSERT | Creates gift card coupon |
coupons | Viva (gift card cancel) | DELETE | Removes coupon if gift card was completed |
product_codes | SmartCart (auto-accept) | UPDATE | Stock decrement via create_order_admin |
shop_customer | SmartCart (auto-accept) | INSERT | Creates guest customer for marketplace order |
shop_order_basket | SmartCart (auto-accept) | INSERT | Order line items for the shop order |
xpay_logging | XPay (both flows) | INSERT + READ | Audit trail and securityToken lookup source |
gift_card_orders | XPay (gift cards) | UPDATE | Status (Completed/Canceled), coupon_id |
Order Status Values
| Status | Meaning | Layer |
|---|---|---|
PENDING | Order created, awaiting payment confirmation | All (legacy webhook + synchronous) |
PAID | Payment confirmed successfully | All |
CANCELED | Payment failed or was rejected | All |
Gift Card Status Values (Spatie Enum)
| Status | Value | Meaning |
|---|---|---|
Pending | 1 | Awaiting payment |
Completed | 10 | Payment confirmed, coupon created |
Canceled | 11 | Payment failed or coupon deleted |
meta_data Field Tracing
The shop_order.meta_data field tracks which code path last modified the order:
| Value | Source |
|---|---|
controller:webhooks/jcc | JCC webhook |
controller:WebhooksHandler:viva | Viva Wallet legacy webhook |
model:set_status:CANCELED:update_order | update_order() when canceling (JCC/Viva legacy) |
model:set_is_paid | set_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
| Group | Key | Used By | Purpose |
|---|---|---|---|
JCC | CALLBACK_TOKEN | AdvJcc | HMAC-SHA256 secret for checksum validation |
SMARTCART | IS_ENABLED | AdvSmartCart | Feature flag to enable/disable SmartCart webhooks |
XPAY | API_KEY | XPay gateway service | X-API-KEY header for Nexi XPay Greece API calls |
XPAY | IS_PRODUCTION | XPay gateway service | Toggles 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 Path | Used By | Purpose |
|---|---|---|
internalApi.useApi | ERP hook | Master switch for internal API hooks |
internalApi.apiOrderWebHooks.erpReady | ERP hook | Base URL for ERP notification endpoint |
internalApi.apiToken | ERP hook | Bearer token for internal API auth |
internalApi.clientConnectTimeout | ERP hook | Guzzle connection timeout (seconds) |
Viva Wallet Config (via Factories::vivaWallet())
| Registry/Config | Purpose |
|---|---|
Viva merchantId | Basic Auth username for webhook verification |
Viva apiKey | Basic Auth password for webhook verification |
Viva isProduction | Determines 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
| Handler | Overridable Methods | Purpose |
|---|---|---|
| AdvJcc | afterOrderSuccessHooks(), validateChecksum(), calculateStatus() | Custom ERP hooks, alternative validation, custom status logic |
| AdvKlarnaPayments | checkPayload(), updateShopOrderKlarnaPayment() | Additional validation, custom storage logic |
| AdvSmartCart | afterOrderSuccessHooks(), checkAndAutoAcceptExpressOrder() | Custom ERP hooks, custom auto-accept logic |
| AdvViva | handleWebhook() (entire method) | Full custom handling |
| Adv_checkout (xPayHook) | processXPayPaymentResult(), logXPayTransaction() — both protected | Custom side effects on PAID/CANCELED, custom audit logging |
| AdvGiftCardPage (xPayHook) | acceptGiftCard(), cancelGiftCard(), acceptPostActions(), cancelPostActions() — all protected | Customize coupon issuance/revocation, post-acceptance side effects |
Adding New Webhook Handlers
To add a new payment webhook in a client repo:
- Create the handler class in
ecommercen/webhooks/Adv{Provider}.php(main repo) or directly inapplication/controllers/webhooks/{Provider}.php(client repo) - Add a route in
application/config/routes.php - If the handler needs to fire the ERP hook, use
OrderForErpHookFireTrait
Business Rules
| Rule | Description | Handler(s) |
|---|---|---|
| Legacy PENDING guard | Legacy Viva/JCC handlers only process orders in PENDING status and exit if the order is in any other state | Viva (legacy), JCC |
| Order existence check | All handlers verify the order exists before processing | All |
| HMAC validation | JCC validates callback authenticity via HMAC-SHA256 checksum | JCC |
| Feature flag | SmartCart requires SMARTCART.IS_ENABLED registry flag | SmartCart |
| ERP notification | ERP hook fires on successful payment (only if internalApi.useApi is enabled) | JCC, SmartCart (direct); XPay via afterOrderSuccessHooks() |
| No emails from legacy webhooks | Legacy webhook handlers do not send confirmation emails -- the synchronous flow handles this | JCC, Viva (XPay is an exception — see Known Issues) |
| Klarna is decoupled | Only stores authorization token; payment capture happens at confirmation stage | Klarna |
| Express auto-accept | Skroutz express orders are automatically converted to shop orders when state becomes accepted | SmartCart |
| Gift card dual lookup | Viva webhook checks both shop_order and gift_card_orders tables | Viva |
| Gift card coupon lifecycle | Accept creates a coupon (5-year validity, 1 use); cancel deletes the coupon if already created | Viva |
| Webhook-first for JCC | JCC synchronous flow defers to webhook result if order is no longer PENDING | JCC |
| Viva pre-auth handling | Synchronous Viva flow detects when webhook already confirmed payment and renders success without reprocessing | Viva |
| Stock management (legacy cancel) | Legacy cancel via update_order triggers set_status('CANCELED') which calls returnOrderStock() to restore stock | JCC, Viva (legacy) |
| Meta data tracing | Each handler writes its identity into shop_order.meta_data for audit trail | JCC, 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 filter | REFUNDED notifications dropped silently by both handlers | XPay |
| XPay sends emails/SMS from webhook | Unlike JCC/Viva, the XPay webhook fires adv_mailer->order_complete(), sendSmsSuccess(), and informLowStock() on PAID | XPay (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 Effect | Legacy Webhook | Synchronous (get_response) |
|---|---|---|
| Update order status | Yes | Yes |
| Fire ERP hook | JCC + SmartCart only (XPay also fires it via afterOrderSuccessHooks()) | Yes (all gateways) |
| Send confirmation email | No (except XPay orders webhook — see Known Issues) | Yes |
| Send confirmation SMS | No (except XPay orders webhook — see Known Issues) | Yes |
| Low stock notification | No (except XPay orders webhook — see Known Issues) | Yes |
| Analytics reporting (Matomo, Meta, Manago) | No | Yes |
| Destroy cart session | No | Yes |
| Guest customer logout | No | Yes |
| Render thank-you page | No | Yes |
Set is_paid = 1 | Legacy 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 placement | All payways — reduceStockForOrder() at PlaceOrderService.php:770 (#282) | All payways — legacy create_order / POS |
| Restore stock on cancel | JCC/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
PaymentConfirmationServiceevent-bus consumers are retired or out of scope for this doc (Advisable-com/ecommercen#721 — see the callout under Business Context, anddocs/decisions/721-retire-rest-webhooks.mdfor the resolution history of each).
XPay webhook does not stamp
shop_order.meta_data: Other webhooks (JCC, Viva legacy) writemeta_dataidentifying the code path.xPayHook()andprocessXPayPaymentResult()omit this. Thexpay_loggingtable provides a parallel audit trail butmeta_datainconsistency makes cross-provider forensics harder. (ecommercen/checkout/controllers/Adv_checkout.php:3906-3986)XPay PAID→CANCELED reversal stock behavior:
cancelOrder()is called with'+'(positive) stock direction arg, restoring stock. Verify whetherset_status('PAID')previously decremented stock; if not, this reversal may double-credit. (ecommercen/checkout/controllers/Adv_checkout.php:4159-4171)XPay CANCELED→PAID resurrection re-sends customer emails/SMS: The PAID branch of
processXPayPaymentResult()always firesadv_mailer->order_complete()+sendSmsSuccess(), with no guard for prior sends. (ecommercen/checkout/controllers/Adv_checkout.php:4141-4157)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)xPaySuccess()writestran_ticketfrom customer-controlled querystring: reads?paymentid=before API verification completes; spoof damages thetran_ticketaudit field only (downstreamgetOrderStatusis byorder_serial), but may affect admin views.PayByBank legacy callback has no authenticationRESOLVED (Advisable-com/ecommercen#619, #780) —Adv_checkout::payByBankResponse()(ecommercen/checkout/controllers/Adv_checkout.php:1062-1133) now scopes the order lookup bypayway='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 viaacknowledgePayByBankCallback()(:1144-1148), removing the serial-enumeration oracle. Seedocs/decisions/619-paybybank-forged-callback.mdanddocs/decisions/780-paybybank-cancel-arm-gate.md.
Tests
| Test File | What It Covers |
|---|---|
tests/Unit/PaymentGateways/NexiXPay/XPayTest.php | 45 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.php | 12 tests on xpayWebhookAction state machine |
tests/Legacy/Webhooks/AdvVivaTest.php + AdvSmartCartTest.php | 38 combined tests: handleWebhook/handle dispatch, payload validation, status transitions via reflection + mocked models |
No unit tests exist for the legacy webhook controllers ( RESOLVED (commit AdvJcc, AdvKlarnaPayments, AdvSmartCart, AdvViva).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.
Related Flows
- CF-06 Order Preview & Checkout --
confirm-paymentclient endpoint usesPaymentConfirmationServicefor idempotent status updates; fullOrderPaidlistener chain - CF-07 Order Confirmation -- order creation that triggers payment
- CF-08 Payment Processing -- synchronous
get_responseflow that complements webhooks - CF-23 Gift Cards -- Viva Wallet gift card purchase and coupon creation
- AD-03 Order Management -- order status lifecycle and admin actions
- AD-11 Marketplace Orders -- Skroutz SmartCart order management in admin
- IN-08 ERP Integrations -- ERP hook fired by JCC and SmartCart webhooks
- IN-20 Order Webhooks -- general order webhook integrations