# Common checkout customizations

> The skeleton every Shoplazza checkout extension shares, then the CheckoutAPI call for each common change: order custom fields, line items, the address form, blocking submission, and localization.

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 `register` looks exactly like a plain read method, and the reference lists both kinds in the same table. But a `register` method takes a callback whose return value the platform consumes. Attach a callback to `registerUiShippingLinesChange` that only writes a log line and returns no list of shipping lines, and the page reads `length` off the `undefined` it 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

1. **Create a checkout extension.** The CLI scaffolds the project and links it to a development store — see [Build a checkout extension](/docs/app/extensions/checkout/add-checkout-extension).
2. **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](/docs/app/extensions/checkout/extension-points).
3. **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.
4. **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.
5. **Read the result.** These methods do not throw. Success and failure both come back as a returned object with a `state` field.
6. **Verify on a real checkout page.** Run `shoplazza app dev`, open checkout in the linked store, and watch the console.

```mermaid
flowchart TD
    A[shoplazza app extension create] --> B["extend() renders your UI at an extension point"]
    B --> C[Poll until window.CheckoutAPI is mounted]
    C --> D["Call CheckoutAPI.namespace.method()"]
    D --> E{"state === 'success'?"}
    E -- Yes --> F[Update your UI]
    E -- No --> G[Read message and errors, then recover]
    F --> H[Verify with shoplazza app dev on the checkout page]
    G --> H
```

## Prerequisites

- A checkout extension project created with the CLI — see [Build a checkout extension](/docs/app/extensions/checkout/add-checkout-extension).
- A slot for your UI. Pick one from [Extension points](/docs/app/extensions/checkout/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 to `Checkout::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](/docs/app/extensions/checkout/checkout-api). 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.

```javascript
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.

:::note
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](/docs/app/extensions/checkout/extension-reference#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.

```javascript
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](/docs/app/extensions/checkout/extension-recipes#scenario-3-api-calls-and-event-listening).

## Step 3: Call the API

Every call is `CheckoutAPI.<namespace>.<method>()`. The [CheckoutAPI reference](/docs/app/extensions/checkout/checkout-api) 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`:

```json
{
  "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:

```json
{
  "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:

```javascript
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`](/docs/app/extensions/checkout/checkout-api#custom-fields) 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`](/docs/app/extensions/checkout/checkout-api#custom-fields), which returns the fields the page already holds, synchronously and without a request.

```javascript
// 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](/docs/app/extensions/checkout/checkout-api#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`](/docs/app/extensions/checkout/checkout-api#line-items) and [`order.removeLineItems`](/docs/app/extensions/checkout/checkout-api#line-items) 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](/docs/app/use-cases/checkout/modify-order-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`](/docs/app/extensions/checkout/checkout-api#shipping-address).

```javascript
// 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`](/docs/app/extensions/checkout/checkout-api#shipping-address), for hiding the optional fields, and `registerShippingAddressSchemaChangeDisable`, for locking fields that already have a value.

:::warning
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`](/docs/app/extensions/checkout/checkout-api#submit-and-validation) 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.

```javascript
// 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](/docs/app/extensions/checkout/extension-points#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`](/docs/app/extensions/checkout/checkout-api#submit-and-validation), which is an `add*Cb` listener and safe to attach freely.

### Localize your UI

[`base.registerLocaleMap`](/docs/app/extensions/checkout/checkout-api#localization) takes one argument: a map of your messages keyed by full locale. Read them back with [`base.formatMessage`](/docs/app/extensions/checkout/checkout-api#localization), which resolves against the buyer's language.

```javascript
// 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](/docs/app/extensions/checkout/checkout-api#localization).

## Verification

1. **Start the extension.** Run `shoplazza app dev` in the extension project. It builds and pushes to the linked store — a build error here is usually the `index.html` import described in Step 1.
2. **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.
3. **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.
4. **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.
5. **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.
6. **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

1. **A `register*` callback that returns nothing crashes the page.** The platform consumes the return value. A logging function attached to `registerUiShippingLinesChange` returns `undefined`. The page then reads `undefined.length`, and React unmounts the whole checkout into an error screen. The buyer cannot finish the order. Attach `on*` 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.
2. **Line prices can still be stale when the price event fires.** When `store.onPricesChange` fires, the totals are up to date but the discounted line prices from `summary.getProductList()` may still be the previous values. Code that reads `finalLinePrice` the instant the event arrives gets the pre-discount number. Do the work in the handler and then check again a few hundred milliseconds later.
3. **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 the `data.lineItems` the call returned. And `properties` goes in as an object but comes back as a JSON string, so `JSON.parse` it before reading a key.
4. **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.
5. **Not every write works on the thank-you page.** `CheckoutAPI` is 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 as `order.updateOrderCustomFields` fail there without sending a request. Check `config.isThankyouPage()` before writing.

## Next steps

- [Extension points](/docs/app/extensions/checkout/extension-points) — every slot you can render into, static and dynamic
- [CheckoutAPI reference](/docs/app/extensions/checkout/checkout-api) — every namespace, with the type of every parameter
- [Checkout extension recipes](/docs/app/extensions/checkout/extension-recipes) — five worked scenarios, including hiding native modules
- [Modify order line items at checkout](/docs/app/use-cases/checkout/modify-order-at-checkout) — a full feature built on `addLineItems` and `removeLineItems`
