Appearance
Payment Methods
The Lynx checkout places orders through Magento's REST endpoint payment-information, the same one the native checkout uses. On the server your payment method stays a standard Magento payment method: Lynx only needs the client-side part, the code that collects a card token, opens a wallet or sends the shopper to a hosted payment page.
That client-side part is a renderer: a Vue component your module ships. It is mounted under the method's radio button in the Payment step when the shopper picks the method.
Claude Code
The Lynx Claude Code skills can add a payment method renderer for you, following this page. See Working with Claude Code.
Which methods show up
The Payment step lists the methods Magento's PaymentMethodManagementInterface::getList() returns for the quote, filtered by MethodInterface::isOffline():
| Method | No renderer registered | Renderer registered |
|---|---|---|
| Offline (Check / Money Order, Cash On Delivery, Bank Transfer, Zero Subtotal Checkout…) | Shown. The order is placed with { method } only | Shown with the renderer |
| Online (gateways) | Hidden | Shown with the renderer |
An online method without a renderer is hidden because whatever it needs from the browser (a nonce, a token) would never be produced, and the order would fail after the shopper clicks Place Order.
For offline methods, the step already prints what the native checkout prints under the method: its Instructions, and for Check / Money Order the Make Check payable to and Send Check to fields.
Online method that needs nothing from the browser
Register an entry without component. The method is then shown like an offline method and placed with { method }:
xml
<item name="vendor_invoice" xsi:type="array"/>How a renderer is registered
Renderers live in the paymentMethodRenderers pool of OOCS\LynxCheckout\Provider\Checkout\Step\Payment, one item per payment method code, declared in your module's etc/frontend/di.xml:
xml
<type name="OOCS\LynxCheckout\Provider\Checkout\Step\Payment">
<arguments>
<argument name="paymentMethodRenderers" xsi:type="array">
<item name="vendor_foo" xsi:type="array">
<item name="component" xsi:type="string">Vendor_PaymentFoo/Components/Foo</item>
<item name="config" xsi:type="object">Vendor\PaymentFoo\Model\Config\Foo</item>
</item>
</argument>
</arguments>
</type>| Key | Required | Meaning |
|---|---|---|
component | No | Path of the renderer: <Module>/<path under src> without extension. It resolves to the .vue file |
config | No | An object implementing OOCS\LynxCheckout\Api\PaymentMethodRendererConfigInterface. Its getConfig() array becomes the renderer's config prop |
recaptcha | No | The reCAPTCHA key that guards placing the order with this method. Default place_order; none skips reCAPTCHA. See Forms, reCAPTCHA and Analytics |
Keep every contributor in etc/frontend/di.xml
Magento merges an area's di.xml over the global one with array_replace on each argument name. If one module declared items in etc/di.xml and another in etc/frontend/di.xml, the frontend list would replace the global one and the first module's renderers would disappear. Lynx's own payment modules declare this pool in etc/frontend/di.xml, so yours must too.
The config class
getConfig() returns everything the renderer needs: public keys, the environment, endpoint URLs, and every string the renderer shows under labels, translated with __(). The renderer never hardcodes text, so the storefront's language and Magento's translation files apply.
php
<?php
declare(strict_types=1);
namespace Vendor\PaymentFoo\Model\Config;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use OOCS\LynxCheckout\Api\PaymentMethodRendererConfigInterface;
class Foo implements PaymentMethodRendererConfigInterface
{
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
) {
}
public function getConfig(): array
{
return [
// Public values only: everything here is printed into the page.
'publishableKey' => (string) $this->scopeConfig->getValue(
'payment/vendor_foo/publishable_key',
ScopeInterface::SCOPE_STORE
),
'labels' => [
'cardNumber' => (string) __('Credit Card Number'),
'required' => (string) __('This is a required field.'),
],
];
}
}- Never return secrets. API secrets stay on the server; only values meant for the browser belong here.
- Keep the constructor light. The Payment step is fetched only when the shopper reaches it, and
getConfig()runs only for methods available on the quote. Put network calls or SDK setup ingetConfig(), not in the constructor. If the class cannot avoid a heavy constructor, register its\Proxyin the pool.
The renderer contract
Every part of the contract is optional. Use only what your payment flow needs.
Props the renderer receives
| Prop | Type | Meaning |
|---|---|---|
config | Record<string, unknown> | What getConfig() returned |
placeOrder | (additionalData?: Record<string, unknown>) => Promise<boolean> | Places the order. Resolves true when it was placed. On failure it shows the error itself and resolves false |
validate | () => boolean | Validates the checkout: a payment method is picked and every required Terms and Conditions box is ticked. true means valid |
What the renderer exposes (with defineExpose)
| Member | Type | Meaning |
|---|---|---|
preparePayment | () => Promise<Record<string, unknown>> | Called by the shared Place Order button before the order is posted. Resolve the data to send (a token, a nonce); reject to stop placing the order |
afterPlaceOrder | (orderId: string) => Promise<string | null> | Called after the order exists. Resolve a URL to send the browser there, or null to go to the success page |
ownsAction | boolean | true hides the shared Place Order button: the renderer draws its own (a wallet button) |
loaded | boolean | false while the renderer is still loading what it needs (an SDK, an iframe). Place Order stays disabled and the checkbox reCAPTCHA is not drawn until it is true. Leave it out if the renderer is ready as soon as it mounts |
Placing an order goes through these steps:
validate() → reCAPTCHA token (if the key is protected) → preparePayment()
→ POST payment-information → afterPlaceOrder(orderId) → redirect URL, or the success pageWhat preparePayment() resolves, or what the renderer passes to placeOrder(), is sent as paymentMethod.additional_data. One key is special: po_number is moved to paymentMethod.po_number, the field of PaymentInterface that Magento's Purchase Order method reads.
The Payment step also handles the billing address, the guest email, Terms and Conditions (agreement_ids) and the reCAPTCHA token. The renderer does not need to send them.
A renderer is mounted the first time its method is picked and stays mounted, hidden, when the shopper switches to another method. Hosted fields keep what was typed.
Three shapes of payment flow
Form: card fields, the shared button submits
The shopper fills a form (hosted card fields, a PO number). The shared Place Order button calls preparePayment(), which tokenizes and resolves the data. Braintree cards (OOCS_LynxPaymentBraintree/Components/Cc), the Stripe Payment Element and Lynx's own Purchase Order renderer (OOCS_LynxCheckout/Components/Checkout/Payment/PurchaseOrder) use this shape. The complete example below is a form.
Button: a wallet that places the order itself
There is no form. The wallet's own button opens its popup or payment sheet, and the token only exists after the shopper approves. Expose ownsAction: true and call placeOrder() yourself. Braintree PayPal, Google Pay, Apple Pay and Venmo, and PayPal Express (OOCS_LynxPaymentPayPal/Components/Express) use this shape.
vue
<script setup lang="ts">
const props = defineProps<{
config: { labels?: Record<string, string> }
placeOrder: (additionalData?: Record<string, unknown>) => Promise<boolean>
validate: () => boolean
}>()
defineExpose({ ownsAction: true })
async function onWalletClick() {
// Do not open the wallet while the checkout still has errors
if (!props.validate()) return
const { token } = await window.FooWallet.open()
await props.placeOrder({ foo_token: token })
}
</script>
<template>
<button type="button" class="l-btn l-btn-primary w-full" @click="onWalletClick">
{{ config.labels?.payWithFoo }}
</button>
</template>A flow that leaves the site before the order exists, and brings the shopper back to a review page, also uses this shape: the renderer controls everything before placeOrder().
Redirect: order first, then the hosted payment page
The order is placed first (pending payment), then the shopper is sent to the gateway's page. The payment URL can usually only be built once the order exists, because the gateway signs it with the order number and amount. Expose afterPlaceOrder(). Stripe Checkout (OOCS_LynxPaymentStripe/Components/StripeCheckout) uses this shape.
vue
<script setup lang="ts">
const props = defineProps<{
config: { paymentUrl: string; labels?: Record<string, string> }
}>()
defineExpose({
async afterPlaceOrder(orderId: string): Promise<string | null> {
// Your module's own controller answers with the signed URL for this order
const response = await fetch(`${props.config.paymentUrl}?order_id=${encodeURIComponent(orderId)}`, {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
const { url } = await response.json()
return url || null
},
})
</script>
<template>
<p class="text-sm text-base-content/80">{{ config.labels?.redirect }}</p>
</template>Resolving null lets the method decide per order whether a redirect is needed. The URL endpoint and the return controller (verify the signature, restore the quote on cancel, send the shopper to the success or failure page) are ordinary frontend controllers of your module that answer with JSON or a redirect. They do not need a Lynx route; you only need one if the return page itself should render with the Lynx theme (see Inertia Pages).
Example: an offline method with its own field
An offline method that needs one value from the shopper, like Magento's Purchase Order needs a PO number, gets a small form renderer. Lynx's own Purchase Order renderer (OOCS_LynxCheckout/Components/Checkout/Payment/PurchaseOrder) is built exactly this way.
Here Vendor_PaymentInvoice adds an Invoice reference field to its offline method vendor_invoice. The payment model and system.xml are a standard Magento offline method and are not shown.
1. The config class returns the labels, translated on the server:
php
<?php
declare(strict_types=1);
namespace Vendor\PaymentInvoice\Model\Lynx;
use OOCS\LynxCheckout\Api\PaymentMethodRendererConfigInterface;
class InvoiceConfig implements PaymentMethodRendererConfigInterface
{
/**
* Labels of the Invoice reference field
*
* @return array
*/
public function getConfig(): array
{
return [
'labels' => [
'reference' => (string) __('Invoice Reference'),
'required' => (string) __('This is a required field.'),
],
];
}
}2. Register the renderer in etc/frontend/di.xml, keyed by the method code:
xml
<type name="OOCS\LynxCheckout\Provider\Checkout\Step\Payment">
<arguments>
<argument name="paymentMethodRenderers" xsi:type="array">
<item name="vendor_invoice" xsi:type="array">
<item name="component" xsi:type="string">Vendor_PaymentInvoice/Components/Checkout/Payment/Invoice</item>
<item name="config" xsi:type="object">Vendor\PaymentInvoice\Model\Lynx\InvoiceConfig</item>
</item>
</argument>
</arguments>
</type>Use the same <type> as in How a renderer is registered.
3. The renderer checks the field in preparePayment() and resolves the value; Lynx sends it as paymentMethod.additional_data:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useCheckoutStore } from 'OOCS_LynxCheckout/stores/checkoutStore'
const props = defineProps<{
config: { labels?: Partial<Record<'reference' | 'required', string>> }
}>()
const checkout = useCheckoutStore()
const reference = ref('')
const error = ref('')
defineExpose({
preparePayment(): Promise<Record<string, unknown>> {
const value = reference.value.trim()
error.value = value ? '' : (props.config.labels?.required ?? '')
return error.value ? Promise.reject(new Error(error.value)) : Promise.resolve({ invoice_reference: value })
},
})
</script>
<template>
<div>
<label for="checkout-invoice-reference" class="block text-sm text-base-content mb-1.5">
{{ config.labels?.reference }} <span class="text-error">*</span>
</label>
<input
id="checkout-invoice-reference"
v-model="reference"
type="text"
autocomplete="off"
:class="checkout.inputClass(!!error)"
@input="error = ''"
/>
<p v-if="error" data-checkout-error class="mt-1 text-sm text-error">{{ error }}</p>
</div>
</template>4. Keep the value on the payment. This part is plain Magento and works the same for Luma: an observer on payment_method_assign_data_vendor_invoice copies the key from additional_data into the payment's additional information, which is saved with the order.
xml
<!-- etc/events.xml -->
<event name="payment_method_assign_data_vendor_invoice">
<observer name="vendor_invoice_assign_reference" instance="Vendor\PaymentInvoice\Observer\AssignReference"/>
</event>php
<?php
declare(strict_types=1);
namespace Vendor\PaymentInvoice\Observer;
use Magento\Framework\Event\Observer;
use Magento\Payment\Observer\AbstractDataAssignObserver;
use Magento\Quote\Api\Data\PaymentInterface;
class AssignReference extends AbstractDataAssignObserver
{
/**
* Copy the invoice reference from additional_data onto the payment
*
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer): void
{
$data = $this->readDataArgument($observer)->getData(PaymentInterface::KEY_ADDITIONAL_DATA);
if (!empty($data['invoice_reference'])) {
$this->readPaymentModelArgument($observer)
->setAdditionalInformation('invoice_reference', (string) $data['invoice_reference']);
}
}
}Show it in the order view with the method's info block, as for any Magento payment method; Lynx's order pages print the payment details the method's info block provides.
For Magento's own Purchase Order, step 4 is not needed: po_number is a field of the payment itself, and Lynx moves it there (see The renderer contract).
Complete example
Vendor_PaymentFoo adds the online method vendor_foo, paid with card fields from a hypothetical gateway SDK. The server side (payment model, gateway commands, system.xml) is a standard Magento payment method and is not shown.
app/code/Vendor/PaymentFoo/
├── etc/frontend/di.xml
├── Model/Config/Foo.php ← the config class shown above
└── view/frontend/lynx/
└── vue/
├── package.json ← the gateway SDK, if it comes from npm
└── src/Components/Foo.vueetc/frontend/di.xml:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="OOCS\LynxCheckout\Provider\Checkout\Step\Payment">
<arguments>
<argument name="paymentMethodRenderers" xsi:type="array">
<item name="vendor_foo" xsi:type="array">
<item name="component" xsi:type="string">Vendor_PaymentFoo/Components/Foo</item>
<item name="config" xsi:type="object">Vendor\PaymentFoo\Model\Config\Foo</item>
</item>
</argument>
</arguments>
</type>
</config>The renderer mounts the card field when it is created, reports loaded once the field is ready, and tokenizes in preparePayment():
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { loadFoo, type FooCard } from '@vendor/foo-js'
import { useCheckoutStore } from 'OOCS_LynxCheckout/stores/checkoutStore'
const props = defineProps<{
config: { publishableKey: string; labels?: Record<string, string> }
}>()
const checkout = useCheckoutStore()
const container = ref<HTMLElement>()
const loaded = ref(false)
const error = ref('')
let card: FooCard | null = null
onMounted(async () => {
const foo = await loadFoo(props.config.publishableKey)
card = foo.mountCard(container.value!)
loaded.value = true
})
defineExpose({
// A ref: the checkout sees it change from false to true
loaded,
async preparePayment(): Promise<Record<string, unknown>> {
const result = await card!.tokenize()
if (!result.token) {
// The gateway's message, or Magento's generic required-field text
error.value = result.message ?? props.config.labels?.required ?? ''
throw new Error(error.value)
}
error.value = ''
return { foo_token: result.token }
},
})
</script>
<template>
<div>
<label class="block text-sm text-base-content mb-1.5">{{ config.labels?.cardNumber }}</label>
<div ref="container" :class="checkout.inputClass(!!error)"></div>
<p v-if="error" data-checkout-error class="mt-1 text-sm text-error">{{ error }}</p>
</div>
</template>On the server, read the token from the payment's additional information, as for any Magento payment method (for example with a DataAssignObserver on payment_method_assign_data_vendor_foo).
Renderers are found automatically: every component under view/frontend/lynx/vue/src of an enabled module goes into the build, and each renderer is its own chunk that loads only when the shopper picks the method. An npm dependency such as a gateway SDK goes in the module's own package.json next to src/, not in the theme's; lynx:theme:setup installs it into the theme. See Frontend Development for theme setup and the build.
Express checkout buttons
Express buttons (PayPal, Apple Pay, Google Pay and similar wallets) sit outside the steps. The shopper clicks, the wallet supplies the address and the shipping rate, and the order is placed without going through the steps. They are ordinary PageBuilder components that the merchant drops into the Express Checkout slot (express) of two components:
| Component | Where the slot is |
|---|---|
Checkout (default_checkout) | Above the steps, followed by a divider to the standard checkout |
Checkout Cart (default_checkout_cart) | In the summary column, under Proceed to Checkout |
The slot only accepts components in the group express_checkout, one of each. The merchant arranges them with the Express Buttons Per Row and Express Buttons Gap settings of the component holding the slot.
Registering the button
Express buttons are registered like any other component, in a .graphqls file under your module's etc/components/, one file per Vue file. See Components for every directive.
etc/components/Foo.graphqls:
graphql
"Foo wallet button: the shopper pays in Foo's sheet and the order is placed without the checkout steps."
type VendorFooExpress @component(
code: "vendor_foo_express"
template: "Vendor_PaymentFoo::Components/Express/Foo"
provider: "Vendor\\PaymentFoo\\Provider\\Express\\Foo"
label: "Foo Express"
group: "express_checkout"
icon: "ph-wallet"
zones: ["content"]
availableOn: ["checkout_index_index"]
) {
"False when the method is not available for this quote"
enabled: Boolean! @optional
"Every string the button shows, translated by the provider"
labels: StringMap! @optional
}template uses :: here, unlike the / of the renderer pool. Declare every key the provider returns (client ids, button style and so on): keys missing from the .graphqls are dropped before they reach the browser. The component types its props as Lynx.VendorFooExpress, the name of the type.
Add "checkout_cart_index" to availableOn only if the button works on the cart page; see On the cart page.
The provider decides whether the button shows
The provider returns an enabled flag along with the button's config and labels. When enabled is false the component renders nothing, and the Express Checkout area hides itself when none of its buttons render anything.
php
<?php
declare(strict_types=1);
namespace Vendor\PaymentFoo\Provider\Express;
use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Payment\Helper\Data as PaymentHelper;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
class Foo extends AbstractComponentProvider
{
public function __construct(
private readonly CheckoutSession $checkoutSession,
private readonly PaymentHelper $paymentHelper,
) {
}
public function resolve(array $props): array
{
$quote = $this->checkoutSession->getQuote();
// Ask the method itself, as the Payment step does: active flag, country, currency, min/max total
$props['enabled'] = $quote->getId()
&& $this->paymentHelper->getMethodInstance('vendor_foo')->isAvailable($quote);
if (!$props['enabled']) {
return $props;
}
return $props + [
'labels' => [
'acceptTerms' => (string) __('Please agree to all Terms and Conditions before placing the order.'),
],
];
}
}Base enabled on the payment method's own isAvailable(), not only on a button-specific config flag. Otherwise the button would still show when the merchant has switched the payment method off. Don't fetch client tokens here: a call to the gateway would hold back the whole checkout page. Let the component fetch what it needs once it is mounted.
The button component
An express button has no placeOrder or validate prop. It works on the quote through the checkout store (OOCS_LynxCheckout/stores/checkoutStore):
| Task | Use |
|---|---|
Call a quote REST endpoint (estimate-shipping-methods, shipping-information, payment-information) | checkout.rest(path, body), which prefixes guest-carts/<masked id> or carts/mine |
Turn the state code a wallet returns ("CA") into region_id | checkout.regionIdByCode(countryId, code) |
| The amount to open the wallet with | checkout.grandTotal?.value (don't open the wallet before it is known) |
| After the order is placed | await checkout.orderPlaced() then window.location.assign(checkout.successUrl) |
| Show an error | useMessagesStore().add('error', message) from OOCS_LynxTheme/stores/global/messagesStore |
| Terms and Conditions | checkout.termsAccepted, checkout.agreementIds |
The usual sequence, as in OOCS_LynxPaymentBraintree/Components/Express/PayPal:
- Open the wallet on the current grand total; the wallet asks for the address.
- Address or rate changed: POST
estimate-shipping-methodswith the partial address the wallet shares, save the chosen rate withshipping-information, and update the wallet's amount from thetotalsit returns. - Shopper approves: save the full address, POST
payment-informationwith the token, callorderPlaced()and go to the success page.
Terms and Conditions. When terms are enabled, the Express Checkout area shows them above the buttons and disables the buttons until every manual term is ticked, because a wallet sheet must open within the click itself. A button that places the order through payment-information must send the agreement ids, since Magento validates them:
ts
await checkout.rest('payment-information', {
paymentMethod: {
method: 'vendor_foo',
additional_data: { foo_token: token },
...(checkout.agreementIds.length
? { extension_attributes: { agreement_ids: checkout.agreementIds } }
: {}),
},
billingAddress,
})Also check checkout.termsAccepted in the click handler, as a second guard.
When the button cannot work, render nothing and show no error: enabled is false, or the wallet reports it does not support this browser.
On the cart page
The checkout store is set up by the Checkout component, so on the cart page checkout.rest() has no cart URL and grandTotal is null. A button that drives the quote through the store works on the checkout page only. A button that only navigates (to the gateway, then to a review page) works on both. This is why Lynx's PayPal Express and PayPal Credit buttons are available on the cart while the Braintree and Stripe express buttons are not.
Things to know
- Third-party SDKs and CSP. Add the gateway's domains to your module's
etc/csp_whitelist.xmlas for any Magento module. The checkout enforces its Content Security Policy with a nonce; an SDK that injects its own inline script must be given that nonce, available ascheckout.cspNonce. For example, PayPal's SDK takes it as thedata-csp-nonceattribute. Test on a full page load of the checkout, not after navigating to it from another page. - Subresource Integrity. The checkout pins the hash of every chunk of the build, renderers included. Load your own code through
import, never by adding a<script>tag with a URL you build yourself. External SDK scripts are not part of this. - Styling. Use the theme's tokens and classes, like
checkout.inputClass(invalid)for inputs, so the renderer follows the merchant's design settings. The smallest text size istext-sm. - Errors. When
preparePayment()rejects, the checkout shows the error's message as an error message and stays on the page, so reject with a message from the gateway or from yourlabels. Mark inline error text withdata-checkout-error, as the checkout's own fields do. - The customer's order page. The order view shows the payment section using the method's info block HTML, as Magento's own order page does. Your method's info block template appears there unchanged.
- Test both paths: a successful order, and each failure (tokenization error, the shopper closing the popup, an expired session).