Common checkout customizations
The way you build a checkout customization is the same every time. You create a checkout extension, render your UI at an extension point, and wait for CheckoutAPI to be ready. Those steps do not change, whatever you are building. Only the last step differs: which method you call. This page walks the shared steps once, then gives the call for each kind of change.
Background
Checkout customization follows one general procedure. Whatever you are adding — a field on the order, an extra line item, a rule on the address form — you go through the same steps and end with a single CheckoutAPI call. The procedure itself is short. Two things make it hard to find:
- Every customization repeats the same four steps before it does the work you care about — scaffold the extension, pick a slot, wait for the API object, read the result. Each new feature re-derives them.
- A method whose name starts with
registerlooks exactly like a plain read method, and the reference lists both kinds in the same table. But aregistermethod takes a callback whose return value the platform consumes. Attach a callback toregisterUiShippingLinesChangethat only writes a log line and returns no list of shipping lines, and the page readslengthoff theundefinedit got back and crashes to an error screen in front of the buyer. The table alone does not tell you which methods carry that risk.
Both come from the same place: the reference answers "what exists", and nothing answers "how do I actually use it".
The goal of this guide is to give you that: one runnable skeleton, the rule for reading a method name before you call it, and a short worked snippet for each of the changes apps ask for most.
How it works
- Create a checkout extension. The CLI scaffolds the project and links it to a development store — see Build a checkout extension.
- Render your UI with
extend(). You pass the name of an extension point and an HTML string, and the platform renders it at that slot. Names are listed in Extension points. - Wait for
window.CheckoutAPI. The platform attaches it only after the checkout page finishes loading, so your script polls for it instead of reading it at startup. - Call the method for your change. Every call has the shape
CheckoutAPI.<namespace>.<method>(), and the name prefix tells you whether it reads, listens, or rewrites. - Read the result. These methods do not throw. Success and failure both come back as a returned object with a
statefield. - Verify on a real checkout page. Run
shoplazza app dev, open checkout in the linked store, and watch the console.
Prerequisites
- A checkout extension project created with the CLI — see Build a checkout extension.
- A slot for your UI. Pick one from Extension points.
extend()always needs an extension point, but some extensions have nothing to put on the page: they only run code, register listeners, or call your own backend. Those go toCheckout::LogicContainer::RenderAfter, a container that renders no visible content and takes up no space in the layout. - The method you intend to call, looked up in the CheckoutAPI reference. Every signature and type on this page comes from there.
Step 1: Render your UI at an extension point
extend() is imported from shoplazza-extension-ui and takes the slot name plus the HTML to put in it. Call it at the top level of src/index.js, before anything else runs.
import { extend } from 'shoplazza-extension-ui';
// The UI as an inline template string. Keep the markup in index.js:
// it is one file to read, and there is no build step to get wrong.
const template = `
<div id="my-widget" style="display:none;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<label>
<input type="checkbox" id="my-toggle" />
<span id="my-label">Gift wrap this order</span>
</label>
</div>
`;
// extensionPoint decides where the HTML lands. This one sits just below the
// discount code input, in the order summary column.
extend({
extensionPoint: 'Checkout::Reductions::RenderAfter',
component: template,
});
Choosing the slot is mostly a question of which module your content belongs next to. Content about the order total goes under Checkout::Reductions::RenderAfter or Checkout::TotalPrice::RenderAfter; content about delivery goes under Checkout::ShippingList::RenderAfter; a notice for the whole page goes at Checkout::RenderBefore. The same slot can be extended more than once, and content is appended in call order rather than replaced.
Write the markup as an inline template string in index.js, as above. The import template from './index.html' form only works when index.html is the assembly entry that pulls in other fragments with import './xxx.html' lines. An index.html holding plain markup and nothing else fails the build with "default" is not exported. The fragment structure is described under the HTML template.
Step 2: Wait for CheckoutAPI
Your script runs early in the page load, when two things may not exist yet: window.CheckoutAPI, which the platform mounts only after the page has fully loaded, and your own DOM, because extend() injects into the slot asynchronously. Poll until both are there.
const MOUNT_INTERVAL_MS = 100;
const MOUNT_MAX_ATTEMPTS = 50; // 100ms * 50 ≈ 5 seconds
// Poll for CheckoutAPI and for the element extend() injected. They finish in
// no fixed order, so wait for both before running any business logic.
function mount(onReady, attempt = 0) {
if (window.CheckoutAPI && document.getElementById('my-widget')) {
onReady(window.CheckoutAPI);
return;
}
if (attempt >= MOUNT_MAX_ATTEMPTS) {
console.warn('CheckoutAPI did not mount within the expected time');
return;
}
window.setTimeout(() => mount(onReady, attempt + 1), MOUNT_INTERVAL_MS);
}
mount((api) => {
document.getElementById('my-widget').style.display = 'block';
// Everything from Step 3 onwards goes here.
});
A longer version of this pattern, which also unregisters its callbacks before re-registering them so a hot reload does not double up, is in Scenario 3: API calls and event listening.
Step 3: Call the API
Every call is CheckoutAPI.<namespace>.<method>(). The CheckoutAPI reference lists every namespace and every method inside it. Before you call anything, read the prefix of the method name — it tells you what kind of call it is, and one of the five kinds can break the page.
| Name pattern | What it does |
|---|---|
get* / is* / has* | Reads a value synchronously and returns it. No request, no side effect. Safe to call as often as you like. |
on* / remove* | on* registers a callback, remove* takes it off. Use them as a pair and pass the same function reference to both. |
register* / unregister* | Registers a rewriter. The platform consumes what your callback returns and renders it. Use these only when you mean to change platform behavior. |
dispatch* | Triggers one UI refresh, for when you changed something the page has not noticed. |
Returns a Promise | Sends a request or runs a validation, so the result arrives later. |
The distinction that matters is on* against register*. onShippingChange watches the buyer's shipping choice and your callback's return value is ignored. registerUiShippingLinesChange decides which shipping lines the page renders, and returning nothing from it leaves the page reading a property off undefined. Both sit in the same table in the reference, one row apart.
A callback registered with on* stays registered until the page unloads. Keep it in a variable so the matching remove* can take it off — a callback written inline as an arrow function can never be removed.
Step 4: Handle the result
Almost nothing here throws. A try/catch around these calls catches nothing and hides the failure. Read the returned object instead: state is 'success' when the call worked, and carries the error code when it did not.
Success, from order.addLineItems:
{
"state": "success",
"message": "success",
"errors": [],
"data": {
"orderId": "2446407205415840740905",
"lineItems": [
{
"id": "18b3f672-8c94-42fc-80c6-8b68ba2b4ebf",
"variantId": "tier-variant-id-290",
"productTitle": "Shipping protection",
"price": "2.90",
"quantity": 1,
"requiresShipping": false
}
]
}
}
Failure, from order.removeLineItems on a removal the platform refuses:
{
"state": "bundled_product_requires_real_item",
"message": "bundled_product_requires_real_item",
"errors": ["bundled_product_requires_real_item"],
"data": null
}
The error code is in state, repeated in message and errors, and data is null. So the check is always the same shape:
const res = await api.order.addLineItems(params);
if (res.state !== 'success') {
// Roll your UI back to what the order actually contains, and tell the buyer.
console.warn('line mutation failed:', res.state, res.errors);
return;
}
Common customizations
Write data to the order
order.updateOrderCustomFields stores your own key-value pairs on the order — a requested delivery date the buyer picked in your widget, a purchase order number a business buyer typed in, an id from your own system. Read them back with order.getOrderCustomFields, which returns the fields the page already holds, synchronously and without a request.
// Write. Fields are merged into what is already on the order, so keys you do
// not mention are left alone and a key that exists is overwritten.
const res = await api.order.updateOrderCustomFields({
customFields: {
delivery_date: '2026-10-08',
// Values are strings only — stringify anything structured yourself.
gift_options: JSON.stringify({ wrap: true, note: 'Happy birthday' }),
},
});
if (res.state !== 'success') console.warn(res.message);
// Read. Returns a Record<string, string>, or {} when the order has no fields.
const fields = api.order.getOrderCustomFields();
const wrap = JSON.parse(fields.gift_options || '{}').wrap;
// Delete. Pass the keys to remove; ['*'] removes every field.
await api.order.updateOrderCustomFields({ deleteCustomFields: ['delivery_date'] });
Limits, all from the Custom fields reference: a key may be neither empty nor *; the write only works while the order is still editable, so on the thank-you page, on a cancelled order, and before the order exists it fails without even sending a request; and the fields have a total length limit, which message names when you cross it.
Add or remove line items
order.addLineItems and order.removeLineItems change what the order contains, and the platform recalculates the whole order afterwards. Adding takes a variantId and a quantity; removing takes the line item id, not the variant id.
The hard part is not the call, it is keeping your own line and the order total reconciled while the buyer applies discounts and changes address. Modify order line items at checkout works that through end to end with an add-on service.
Customize the address form
The address namespace rewrites the form rather than replacing it: reorder the fields, change their labels, hide the optional ones, lock the ones that already have a value, and add validation rules on top of the built-in ones. Which fields exist at all is decided by the country's address template, which you can inspect with address.isFieldShow.
// Relabel. The callback returns a map of every label you are overriding,
// keyed by field id; fields you leave out keep their platform label.
api.address.registerShippingAddressSchemaChangeLabel(() => ({
company: 'Company (required for invoicing)',
address1: 'Floor / suite',
}));
// Make a field required and add a rule of your own. required and validates
// are applied on top of the platform's built-in checks.
api.address.registerShippingAddressSchemaChangeValidateRule(() => ({
company: {
required: true,
validates: [
{ id: 'company-min', message: 'Enter the full legal company name', regexp: '.{3,}' },
],
},
}));
// Reorder. The callback receives the items and MUST return all of them.
api.address.registerShippingAddressSchemaSort((items) =>
[...items].sort((a, b) => (a.id === 'company' ? -1 : b.id === 'company' ? 1 : 0)),
);
// Tell the form to re-render after you register these.
api.address.dispatchShippingAddressSchemaChange();
Two more rewriters in the same namespace take a map keyed by field id: registerShippingAddressSchemaChangeVisibility, for hiding the optional fields, and registerShippingAddressSchemaChangeDisable, for locking fields that already have a value.
Every callback here is a register* rewriter. Return the complete map or the complete list every time — a callback that returns undefined, or a sort callback that drops entries, hands the form a value it cannot render, and the checkout page crashes. Build the return value from the argument you were given rather than from a list you wrote by hand.
Block submission
order.registerBuyerJourneyIntercept runs when the buyer tries to move on. Return { behavior: 'block' } to stop them — for an age check, a purchase limit, a country your merchant does not ship to — or { behavior: 'allow' } to let them through.
// pointId names the dialog the platform opens when you block. You render its
// contents into the matching dynamic extension points.
const POINT_ID = 'age-gate';
api.order.registerBuyerJourneyIntercept(() => {
const confirmed = document.getElementById('age-confirm').checked;
if (confirmed) return { behavior: 'allow' };
return { behavior: 'block', pointId: POINT_ID, hideFalseBtn: true };
});
// The dialog body is a dynamic extension point named after that pointId.
extend({
extensionPoint: `Checkout::Dialog-${POINT_ID}::RenderAfter`,
component: '<p>Confirm you are over 18 before placing this order.</p>',
});
The dialog slots — body, footer, and the two buttons — are listed under Dynamic extension points. This is a register* method too: it must return one of the two behaviors on every call, including the calls where you have nothing to say.
For a check that runs before the address is submitted rather than at the journey gate, use order.addBeforeSubmitCb, which is an add*Cb listener and safe to attach freely.
Localize your UI
base.registerLocaleMap takes one argument: a map of your messages keyed by full locale. Read them back with base.formatMessage, which resolves against the buyer's language.
// One argument only — the whole map, keyed by full locale tag.
api.base.registerLocaleMap({
'en-US': { 'giftwrap.label': 'Gift wrap this order', 'giftwrap.fee': 'Adds {fee}' },
'zh-CN': { 'giftwrap.label': '为此订单添加礼品包装', 'giftwrap.fee': '加收 {fee}' },
});
// Second argument is the fallback when the key is missing for this language;
// third fills the {placeholders}.
const label = api.base.formatMessage('giftwrap.label', 'Gift wrap this order');
const fee = api.base.formatMessage('giftwrap.fee', 'Adds {fee}', {
// formatPrice turns the amount into a display string with a currency symbol.
fee: api.base.formatPrice(4.5),
});
document.getElementById('my-label').textContent = label;
Without a defaultMessage a missing key comes back as the key itself, which is how a raw giftwrap.label ends up on a buyer's screen. Always pass the fallback. Nested keys are flattened with a dot, so { giftwrap: { label: … } } is read as 'giftwrap.label'. See Localization.
Verification
- Start the extension. Run
shoplazza app devin the extension project. It builds and pushes to the linked store — a build error here is usually theindex.htmlimport described in Step 1. - Open checkout in the linked store. Add a product on the storefront and go through to the checkout page. Your UI appears at the slot you named in
extend(), and nowhere else. - Confirm the API mounted. With the console open, your widget becomes visible rather than staying at
display:none. If it never appears,mount()timed out and the warning is in the console. - Exercise the change. Make the change your extension exists for, then read it back with the matching
get*method and confirm the value is what you wrote. - Check the console. No errors, and no "Oops, there is an error" screen after any interaction — that screen usually means a
register*callback returned the wrong value. - Try the thank-you page. Complete the order. Your extension should not raise errors there, and should not attempt writes that fail.
Restart shoplazza app dev after you change the source, and after any change to extension.json, which the dev server does not watch.
Notes
- A
register*callback that returns nothing crashes the page. The platform consumes the return value. A logging function attached toregisterUiShippingLinesChangereturnsundefined. The page then readsundefined.length, and React unmounts the whole checkout into an error screen. The buyer cannot finish the order. Attachon*listeners when you only want to watch, and when you do register a rewriter, return a value of the type in its signature on every path through the callback. - Line prices can still be stale when the price event fires. When
store.onPricesChangefires, the totals are up to date but the discounted line prices fromsummary.getProductList()may still be the previous values. Code that readsfinalLinePricethe instant the event arrives gets the pre-discount number. Do the work in the handler and then check again a few hundred milliseconds later. - Line item ids change, properties come back as a string. Remove a line and add it again and the new line has a different
id, so never cache one — read the latest from thedata.lineItemsthe call returned. Andpropertiesgoes in as an object but comes back as a JSON string, soJSON.parseit before reading a key. - One line change costs seconds, not milliseconds. Every add or remove triggers a full recalculation of the order. A change that is really two mutations, removing one line and adding another, takes roughly 2 to 4 seconds end to end. Disable your control and show a loading state for the whole round trip, or the buyer clicks again and you run two overlapping mutations.
- Not every write works on the thank-you page.
CheckoutAPIis mounted on the thank-you page too and the read methods still answer, which makes it look like everything is available. It is not: the order is no longer editable, so writes such asorder.updateOrderCustomFieldsfail there without sending a request. Checkconfig.isThankyouPage()before writing.
Next steps
- Extension points — every slot you can render into, static and dynamic
- CheckoutAPI reference — every namespace, with the type of every parameter
- Checkout extension recipes — five worked scenarios, including hiding native modules
- Modify order line items at checkout — a full feature built on
addLineItemsandremoveLineItems