qliro / module-qliroone
QliroOne checkout integration for Magento 2
Requires
- ext-json: *
- guzzlehttp/guzzle: ^7.5
- magento/framework: ^103.0
Requires (Dev)
- magento/module-inventory-catalog-api: ^1.3
- magento/module-inventory-configuration-api: ^1.2
- magento/module-inventory-sales-api: ^1.2
- magento/module-quote: ^101.2
- phpunit/phpunit: ^10.5
Suggests
- magento/module-inventory-catalog-api: Decide stock the way Multi Source Inventory decides it
- magento/module-inventory-configuration-api: Decide stock the way Multi Source Inventory decides it
- magento/module-inventory-sales-api: Decide stock the way Multi Source Inventory decides it
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 1.7.48
- 1.7.43
- 1.7.42
- 1.7.35
- 1.7.23
- 1.7.17
- 1.7.12
- 1.7.11
- 1.7.10
- 1.7.9
- 1.7.8
- 1.7.7
- 1.7.6
- 1.7.5
- 1.7.4
- 1.7.2
- 1.7.1
- 1.7.0
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.9
- 1.5.8
- 1.5.7
- 1.5.4
- 1.5.3
- 1.5.0
- 1.4.8
- 1.4.7
- 1.4.6
- 1.3.0
- 1.2.3
- dev-PLIN-371-roll-strict-types-out
- dev-PLIN-376-accept-qliro-shipping-until-validation
- dev-PLIN-370-move-controllers-off-action
- dev-PLIN-369-repair-the-transaction-response
- dev-PLIN-426-hide-the-magento-shipping-step
- dev-PLIN-408-express-the-cart-total-exactly
- dev-PLIN-417-keep-the-company-the-buyers-own
- dev-PLIN-363-set-timeouts-on-the-http-client
- dev-PLIN-367-refuse-a-fractional-quantity
- dev-PLIN-419-add-the-embedded-iframe-payment-mode
- dev-PLIN-421-read-the-line-reference-format-from-the-reservation
- dev-PLIN-406-decide-stock-the-way-msi-does
- dev-PLIN-408-keep-the-order-line-reference-unique
- dev-PLIN-367-unit-test-the-payload-builders
- dev-PLIN-366-harden-the-callback-token
- dev-PLIN-365-redact-the-log
- dev-PLIN-364-prune-the-log-table
- dev-PLIN-362-round-every-outbound-amount-in-one-place
- dev-PLIN-376-rate-the-quote-in-its-own-store-view
- dev-PLIN-374-show-the-payment-method-name-on-the-order
- dev-release/RC2.0
- dev-QLI-171
- dev-PLIN-361-vat-rate-on-shipping-and-fee-lines
- dev-PLIN-376-say-what-produced-no-shipping-rates
- dev-PLIN-389-stop-the-preset-address-leaking-the-store-name
- dev-PLIN-390-set-the-order-session-keys-on-the-qliro-success-page
- dev-PLIN-360-send-the-vat-of-the-discount
- dev-PLIN-376-vajper-fixes-and-249-support
- dev-PLIN-381-capture-once-per-order
- dev-magento-249-compatibility
- dev-PLIN-376-replace-the-country-guessed-at-create-time
- dev-PLIN-378-reject-empty-link-lookups
- dev-PLIN-376-refresh-the-order-when-qliro-masks-the-address
- dev-PLIN-376-push-shipping-methods-after-fetching-the-qliro-order
- dev-PLIN-376-fix-shipping-methods-missing-on-first-attempt
- dev-PLIN-373-pin-order-management-to-placed-qliro-order
- dev-PLIN-358-round-discount-vat-rate
- dev-PLIN-305-fix-items-limit-validator-main
- dev-QLI-230
- dev-QLI-82-2
- dev-QLI-241
- dev-QLI-224
- dev-QLI-212
- dev-QLI-118
- dev-QLI-85-V2
- dev-QLI-190
- dev-QLI-118-N
- dev-QLI-85
- dev-QLI-193
- dev-QLI-170
This package is auto-updated.
Last update: 2026-09-27 21:58:15 UTC
README
Qliro One for Magento 2 is an extension that integrates the Qliro One payment and checkout service into the Magento 2 e-commerce platform. Qliro One is a Nordic payment solution offering invoice, part payment, card payment, and direct bank payment options. The Magento 2 module enables seamless embedding of Qliro’s hosted checkout within the store, supporting features such as dynamic shipping options, order management synchronization, and compliance with local payment regulations. The module is a fully functional implementation of a custom checkout that uses Qliro One functionality through its API.
All documentation, setup guides, and troubleshooting instructions are maintained in the Wiki.
👉 Go to the Wiki
Quick links
The Wiki is organized into the following main sections to help you quickly find what you need:
-
Installation & Update - How to install the module and keep it up to date
https://github.com/Qliro/Magento-2/wiki#installation--update -
Configuration - Learn how to configure the module for your store
https://github.com/Qliro/Magento-2/wiki#configuration -
Customization and tech details - Database tables, events, plugins, logs, and customization guidelines
https://github.com/Qliro/Magento-2/wiki#customization-and-tech-details -
Troubleshooting - Common issues and how to resolve them
https://github.com/Qliro/Magento-2/wiki#troubleshooting
Analytics and purchase tracking
The checkout has its own success page, checkout/qliro/success, so its layout handle is
checkout_qliro_success and not checkout_onepage_success. A tracking extension that declares
its block in checkout_onepage_success.xml renders nothing here until that block is mapped onto
this handle, in your own module or theme:
<!-- view/frontend/layout/checkout_qliro_success.xml --> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="content"> <block class="Vendor\Tracking\Block\Purchase" name="vendor.tracking.purchase" template="Vendor_Tracking::purchase.phtml" cacheable="false"/> </referenceContainer> </body> </page>
<update handle="checkout_onepage_success"/> brings every block of the core success page over in
one line, tracking blocks included, but it also brings checkout.success and
checkout.registration, which duplicate what this module's own success block already shows.
On the success page the module provides what core provides:
- the checkout session carries
last_order_id,last_real_order_id,last_quote_id,last_success_quote_idandlast_order_statusfor the placed order, so an extension that identifies the order throughgetLastRealOrderId()works unchanged - the
checkout_onepage_controller_success_actionevent is dispatched withorder_idsandorder, once per order: reloading the success page does not fire it a second time
Magento's own GA4 block needs nothing, it is declared in Magento_GoogleGtag's default.xml and
therefore renders on every page, this one included.
Client side tracking undercounts on this checkout. An order can be placed by Qliro's
checkoutStatus callback while the buyer is still in the Qliro iframe, so a buyer who closes the
tab or never returns from a bank app produces a paid order and no browser event at all. If the
numbers have to be right, send the purchase server side, GA4 Measurement Protocol from an observer
on sales_order_place_after, and offline conversion import or server side GTM for Google Ads.
What the module logs
Every API call and callback is logged to the qliroone_log database table and to
var/log/qliroone.log, request and response bodies included. This happens at debug level on every
request whatever Debug Mode is set to: that setting gates other behaviour, not the logging.
Before anything is written, Model/Logger/Redactor.php masks it as [redacted]:
- Credentials, always, by name, by value and inside a url. Any key named like one,
MerchantApiKey,MerchantApiSecret,Authorization, a token, a password, or anything containingsecretorapikey, whatever its spelling and however deep in the payload it sits. The API key and secret the store is configured with are masked wherever they appear, whatever the key they were logged under is called. In a url, the user and password before the host, atokenin the query and a JSON web token anywhere are masked too: the callback token carries the merchant API key in its payload, and with Callback HTTP Auth the url carries the username and password. - Customer data, always. Email, mobile number, personal identity number, VAT and organization
number, date of birth, first and last name, care of, company name, street, postal code and city,
under Qliro's spellings and Magento's own,
taxvat,dob,vat_idandcompanyincluded, and whether the field holds one value or a list. An email address and a Nordic identity number are masked wherever they appear in free text too. - An identity number, written with its separator or as twelve digits, always. A ten digit one written without a separator is not masked by pattern: it cannot be told apart from a Qliro order id, and it arrives under a key of its own in every payload the module sends or receives.
- An international phone number, on the lines marked with the
sensitivetag. That is every exchange with Qliro's APIs, every callback body Qliro posts back and every refusal Qliro explains, which is where a customer record travels.
Card data is not masked, and that is deliberate: Qliro sends only the first six and the last four
digits of a saved card, CardBin and CardLast4Digits, which is what PCI DSS permits a merchant to
retain and what support needs to identify a card. The card token itself is masked.
What stays readable is what a merchant needs in order to investigate: the merchant reference, the
endpoint and the uri with their order ids, the request method, the status code, the order items, the
amounts, the country and Qliro's own error codes. Those keep their digits even on a masked line, so
an order id is never mistaken for an identity number. An exception keeps its class, its file, its
code and its message and trace, with both masked. A merchant reference written as a date and a
counter, 20260909-0001, has the shape of an identity number, so the reference of the line being
written is held out of the patterns by value: it stays readable wherever it appears in that line,
while any other value of that shape is still masked.
The masking runs on the log channel, so it applies to the table and to the files alike, to a payload that arrives as an object or as text, and to anything logged from a plugin of your own that uses the module's log manager.
How long rows are kept is a separate matter from what they hold, and it is the next section.
Log retention
The module logs every API call and callback, payloads included, to the qliroone_log table, whatever
Debug Mode says. The nightly cron job qliroone_prune_log deletes the rows older than Stores >
Configuration > Sales > Payment Methods > QliroOne Checkout > Debugging > Log Retention (days), 30 days
on a fresh install. Set it to 0 to keep every row.
Upgrading from a version before 1.7.30: an installation whose log table already holds rows keeps every row, so nothing is deleted until you choose a window. Set the retention to 30, or to whatever the store needs, to start pruning.
The setting lives on the default scope only. A log row carries no store id, so one window covers the whole table, and a window saved on a website or a store view is refused rather than silently ignored.
The same pruning runs on demand:
bin/magento qliroone:log:prune # the configured retention
bin/magento qliroone:log:prune --days=7 # this run only, the setting is untouched
Rows are deleted in batches of 5000, and a single run stops after 200 of them, so the job is safe to run while the store is serving traffic. A backlog of tens of millions of rows is worked off over several runs, and a run that stopped at that cap says so rather than looking like a finished one.
API timeouts
Every call to Qliro carries a connect timeout and a request timeout, in seconds, set under Stores > Configuration > Sales > Payment Methods > QliroOne Checkout > API Timeouts. Before 1.7.45 the HTTP client ran with Guzzle's defaults, which are neither, so a connection Qliro never answered held a PHP worker until the web server killed it and the customer watched a spinner for as long as that took.
Two pairs, and the call says which one it wants, because the class it goes through cannot: the same client fetches the order for the checkout page and for the status push Qliro sends afterwards, and the same admin client serves the order screen an admin is looking at and the capture behind a shipment.
| Setting | Default | Applies to |
|---|---|---|
| Connect Timeout, Somebody Waiting | 5 | Checkout render, quote update, shipping change, the admin order screen |
| Request Timeout, Somebody Waiting | 15 | The same calls, whole call |
| Connect Timeout, Nobody Waiting | 5 | Capture, refund, cancel, status push, the pending page poll |
| Request Timeout, Nobody Waiting | 60 | The same calls, whole call |
The first pair is short because somebody is watching the page it renders: a store that would rather show an error than a spinner can cut it further. The second is longer because nobody is, and abandoning a capture Qliro has already accepted is worse than waiting for the answer.
The request timeout covers the whole call, connecting included, so a request timeout shorter than the connect timeout is the one that decides: the connect never gets the window it was given. Setting the request timeout to the longest a call may take and the connect timeout to a few seconds is the useful shape.
Both are per store view, and both accept 1 to 300 seconds. A field left empty, or holding anything that is not a whole number of seconds, falls back to the default rather than to no timeout: 0 means "wait forever" to Guzzle, which is the state these settings exist to end.
A call that runs out of time fails the way a refused call already does, as a TerminalException, so the
checkout answers the customer with its own message and the order management screens report the failure.
It is logged with the same >>> and <<< lines as any other call, so a store that times out often is
visible in qliroone_log rather than only in the web server's error log.
A capture or a refund that runs out of time may have been booked by Qliro before the answer was lost.
Magento rolls its own document back, so the merchant invoices or refunds again, and that second attempt
carries the same RequestId as the first: Qliro books a repeated id once. The id is built from what is
being settled, the order, what it had already settled, the transactions and the amounts, because the
invoice and the credit memo have no id of their own until Magento saves them, which happens after the
call. A settlement the merchant really means a second time differs in what the order had already settled
by then, so it is a request of its own and Qliro books it.
Callback security
Qliro pushes order and transaction updates to callback urls this module registers on the order when it is created. Each url carries a token this module signed with the store's API secret, and every callback controller refuses a request whose token does not verify.
The token expires. Stores > Configuration > Sales > Payment Methods > QliroOne Checkout > Notification Callbacks > Callback Token Lifetime (days) decides how long a newly minted one lasts, 1 to 1095 days, and 1095 by default.
Shorten it to match your order lifecycle. The url Qliro pushes to is the one registered when the order was created, so it has to still be valid when the last capture or refund of that order settles. The default is three years, the length of the Swedish right of complaint, so that no store is caught out by it. If your orders are done sooner, set it shorter: that is the whole point of the setting.
What an expired token costs, so the choice is an informed one: the callback is refused, the capture or the refund is never confirmed on the Magento order, an order that was held awaiting capture confirmation stays held, a queued sequential refund stops advancing, and nothing reconciles any of it afterwards, because the module does not poll Qliro for status. A refusal for this reason is logged as a warning naming the setting, so the symptom points at the cause.
Changing the setting is safe at any time: the expiry is written into each token, so a callback url already registered with Qliro keeps the lifetime it was given, and a shorter window applies only to orders created after the change. Tokens issued before this feature existed carry the old three year expiry and keep working until it passes.
On upgrade nothing changes for the orders you have already placed: their callback urls carry the expiry they were minted with, three years for anything from before this release, and the check reads it from the token rather than from the setting. Orders placed after the upgrade get the new default, so this is the moment to decide whether a year covers your returns.
The token the checkout page uses for its own ajax calls is a different one: it lasts two hours, is bound to the quote, and is not affected by this setting.
Stock
The module asks the same inventory Magento asks. On a store with the Multi Source Inventory modules
installed, a line is judged by the salability of its sku in the stock of the website's sales channel,
for the quantity in the cart, the way MSI judges it when it takes the stock for an order. On a store
without them, the answer comes from cataloginventory_stock_item as before, which keeps a single scope
for the whole installation whatever the website. Nothing needs configuring, and the inventory modules are
not required: the module ships no dependency on them and reads the store.
Two things are decided this way: whether the validate callback declines an order for stock, and the
OutOfStock flag the module sends Ingrid on each order line. Both read the cart the same way, so they
cannot disagree: a line with children is answered by its children, a child counts for its own quantity
times its parent's, and the same sku on two lines is one quantity.
A bundle, a configurable or a grouped product keeps no quantity of its own. Its stock row reads as a
quantity of nothing and Magento never asks it, so neither does the module: the children carry the stock
and are checked in its place. If the inventory cannot be read at all, the line is let through and the
reason is logged, however often it happens: Magento checks the stock again for real when it places the
order, so an unreadable inventory costs nothing here, while refusing on it would decline every order the
store has. Watch the log for Could not read stock if a store's inventory needs looking at.
Where Qliro appears in the checkout
Payment Methods > QliroOne Checkout > General > Show as payment method decides whether the store keeps the native Magento checkout and offers Qliro as one payment method in it, or replaces the checkout with Qliro's own page. With it on, Payment method display decides what happens once the buyer picks Qliro:
- Redirect to Qliro checkout page sends the buyer to the standalone Qliro page. This is what the setting did before it existed, and it stays the default.
- Embedded iframe in checkout opens Qliro in the payment panel, and the buyer never leaves the checkout.
The iframe is fetched when the buyer picks Qliro, not when the payment step loads, so a buyer who pays with something else never creates a Qliro order.
In the iframe the native checkout owns identity, address and delivery, because it collected all three before Qliro was shown. The customer block reaches Qliro locked, so the widget states it and offers neither its change button nor the personal number lookup, and Qliro is sent only the delivery method the buyer already chose, so its own delivery picker has nothing to offer and cannot move the order off the method Magento rated. The order is created for the country on the quote, and that country stays on the quote: everywhere else it comes from the country selector, GeoIP and the store default, and is written back, which here would replace a country the buyer chose with one they did not.
The lock holds the whole block, the phone number with it: Qliro offers no way to keep one field of a locked block open. The buyer changes the phone where they entered it, in the checkout step above the widget, and a store whose buyers need to correct it inside Qliro should stay on the redirect mode, where Qliro owns the form.
Three things fall back rather than trap the buyer. An address that is still empty is not locked, and neither is the block around it, which matters for a virtual cart, where the native checkout collects the billing address inside the payment step and it can still be blank when Qliro is picked. A chosen delivery method that is not among the rated ones sends the whole list and logs why, because the cost of delivery travels on that list and has no line of its own. A Qliro order that cannot be built at all leaves a message in the panel and the buyer can try again.
A buyer who has already paid and comes back to the checkout, with the Back button or a reopened tab, is sent to the pending page that waits for their Magento order, which is what the standalone checkout does too.
Quantity
Qliro carries the quantity of an order line as a whole number, so a store selling by weight or length cannot take half a metre of cable through this payment method. A cart holding a part of an item is refused with a message naming the line, at the point a Qliro order would be created for it, and the buyer can change the quantity or pay another way. That point is the same in every mode, the Qliro checkout page, the payment method in Magento's own checkout and the merchant payment, and on the checkout page the message is shown in place of the widget. It is not truncated: half a metre sent as none would be a line Qliro never charges for, and two and a half sent as two would charge for less than the cart holds, with Magento recording the whole of it either way.
A product configured with is_qty_decimal, or with a qty_increments that is not a whole number, is
what produces such a cart. An order that already holds one, placed before this release or through the
admin or the API, is refused at the capture and at the shipment for the same reason, naming the line,
and has to be settled outside Magento. The refund the module sends is a single line for the amount of
the credit memo and carries no quantity, so it is unaffected.
📘 Documentation: For complete guides, detailed instructions, and technical references, please refer to the Wiki.