# Function extension development guide

> Build a Shoplazza Function extension end to end: write cart-pricing logic in JavaScript, compile to WASM, publish with the CLI, bind it to a store, and go live.

# Function Extension Development Guide

A **Function extension** lets your app run custom logic **on Shoplazza's servers** instead of in the browser. You write the logic in JavaScript, the Shoplazza CLI compiles it to a WebAssembly (WASM) module, and the platform runs it — sandboxed and deterministic — at defined points in a merchant's store.

Function development is **part of app development**: a function lives inside an app project, is published against your app's `client_id`, and goes live together with the app. This guide covers the whole path, from scaffolding to go-live.

## What a function can do

The current function type is **`cart_transform`**. It lets you adjust **line-item pricing** in the buyer's cart. When a `cart_transform` function is bound to a store, Shoplazza runs your code and applies the result to each cart line.

A `cart_transform` function can modify two fields per line item:

| Output field | Effect | Constraints |
|--------------|--------|-------------|
| `price.adjustment_fixed_price` | An amount **added to** the product's original unit price. The line's final unit price = original price + `adjustment_fixed_price`, and the line total recalculates as `price × quantity`. | Decimal string, **non-negative**, ≤ `999999999`, rounded to 2 decimals |
| `title` | Overrides the line item's displayed **title**. The override shows only at checkout; the cart page keeps the original title. | ≤ 255 characters |

The only supported operation is `update` (modify an existing line). A function has **no UI** — it is pure server-side logic that reads a cart context and returns pricing operations.

:::info Why WASM?
Running as WASM lets your logic execute on Shoplazza's infrastructure deterministically and sandboxed, with no network round-trip to your backend on every cart change. You keep writing JavaScript; the CLI compiles it with the [Javy](https://github.com/bytecodealliance/javy) toolchain, and the platform runs it on a Wasmtime engine.
:::

## Where it runs: cart **and** checkout

A single `cart_transform` function you publish runs in **both** of these contexts automatically — you do not write two functions:

| Surface | When it runs | What it affects |
|---------|-------------|-----------------|
| **Cart page** | On **add to cart**, and when a line's **custom properties change** (a pure line deletion does not re-run it) | The line `price` and `line_price` the buyer sees in the cart |
| **Checkout** | On checkout **creation**, when **lines are added/removed/changed** at checkout, and on checkout **validation** — always **before** discounts | The line prices the order is built from |

So a price adjustment computed by your function is reflected on the cart page *and* carried into checkout. At checkout, your function always runs **before** any order/line discounts, so discounts apply on top of your adjusted prices. Order-level and shipping discounts are handled by separate discount endpoints and are **out of scope** for `cart_transform`; a `cart_transform` function only changes line price and title.

:::note
Because the function re-runs on every re-pricing event (cart add/update, checkout create/mutate/validate), author your logic to be **deterministic from the current cart contents** — don't rely on state from a previous run — so the cart and checkout results always agree.

A few stores still use the legacy checkout. In those stores, checkout applies only discounts and does not run `cart_transform`. Stores on the current checkout are not affected.
:::

:::tip Testing price changes
One exception to the re-run rule above: increasing the quantity of a line that is already in the cart keeps the price calculated when the line was added. After you change the function or its metafield values, remove the line and add the product again to see the result of the current function.
:::

## How it works

```
  src/index.js
      │  javy build
      ▼
  <name>.<hash>.wasm
      │  function release / app deploy
      ▼
  the Function runtime   (registry + WASM engine: Wasmtime + Javy)
      ▲
      │  run(input JSON) ──▶ output JSON
      │
  cart service / checkout service
      │  reads the store's bound cart_transform endpoint
      ▼
  applies {operation:{update:[…]}} to each line's price
```

- **the Function runtime** — a registry that stores your function's source + compiled WASM and a WASM execution engine (Wasmtime + Javy/QuickJS). It runs your module against a JSON input and returns a JSON output.
- **cart / checkout services** — when a buyer acts, they look up the function bound to that store and call the Function runtime to execute it, then apply the result to line prices.

## Prerequisites

- The **[Shoplazza CLI](/docs/app/developer-tools/shoplazza-cli)** installed and authenticated (`shoplazza auth login`).
- An app project created with `shoplazza app init`. See [Build your first app](/docs/app/developer-tools/shoplazza-cli/tutorials/build-your-first-app).
- The right scopes in `shoplazza.app.toml`. Cart-transform needs `write_cart_transform` (add `read_cart_transform` to read cart data). The default app template already ships with:

  ```toml
  scopes = "read_customer write_cart_transform"
  ```

:::note
You do **not** need to install Javy yourself. The first time you compile, the CLI downloads and pins the correct Javy release (currently `v5.0.1`) and verifies its checksum.
:::

---

## Step 1 — Create the function extension

From your app project root:

```shell
shoplazza app extension create --type function --name my-function
```

The name must be a **bare directory name** — lowercase letters, digits, `-` or `_`, starting with a letter or digit (`^[a-z0-9][a-z0-9_-]*$`). Path separators and `..` are rejected.

This scaffolds:

```
extensions/
└── my-function/
    ├── .gitignore
    ├── package.json
    ├── shoplazza.extension.toml   # Extension config (id, name, type = "function")
    └── src/
        └── index.js               # Your function entry point
```

The generated `shoplazza.extension.toml`:

```toml
id = ""
name = "my-function"
type = "function"
```

`id` stays empty until your first publish, which writes the function id back (Step 5).

---

## Step 2 — Write the function logic

Your logic lives in `extensions/my-function/src/index.js`. A function follows the **Javy execution model**: at runtime it reads a JSON **input** from `stdin`, computes a result, and writes a JSON **output** to `stdout`.

### Input: the cart context

Your function receives this JSON on `stdin` (field names are the real contract):

```jsonc
{
  "cart": {
    "line_items": [
      {
        "id": "line-id",                 // line item id (use as the operation target)
        "quantity": 2,
        "properties": "[]",              // JSON string of line custom properties
        "product": {
          "product_id": "...",
          "variant_id": "...",
          "price": "19.90",              // current unit price (decimal string)
          "product_title": "T-Shirt",
          "metafields": [                 // product metafields you declared (see below)
            { "namespace": "custom", "key": "markup", "value": 3 }
          ]
        }
      }
    ],
    "attributes": "[]"                   // JSON string of cart-level attributes
  },
  "currency_settings": { "actual_rate": "1.0" },
  "shop": {
    "metafields": [                       // shop metafields you declared
      { "namespace": "custom", "key": "vip", "value": true }
    ]
  }
}
```

Metafields are only included if your function declares which ones to read (see [Declare the metafields your function reads](#declare-the-metafields-your-function-reads)). Up to 5 metafield queries per function.

### Declare the metafields your function reads

A function cannot look data up on its own. Apart from the cart itself, any data it needs to check a buyer's choice or calculate a price — a fee, a price table, a set of rules — must live in a product or shop metafield. The platform loads the metafields you declare before the function runs and puts them into the function input.

List each metafield your function needs as a `[[metafields_query]]` block in `shoplazza.extension.toml`:

```toml
id = ""
name = "my-function"
type = "function"

[[metafields_query]]
resource = "product"
namespace = "custom"
key = "markup"
```

| Field | Description |
|-------|-------------|
| `resource` | `product` (appears in `cart.line_items[].product.metafields`) or `shop` (appears in `shop.metafields`) |
| `namespace` | Metafield namespace |
| `key` | Metafield key |

Each declared metafield arrives as `{ namespace, key, value }`. If a product has no value for a declared metafield, that entry is simply absent from its `metafields` array.

- Up to **5** entries.
- The CLI validates the declaration during `app deploy` (and `app dev`) and submits it with the release. An invalid declaration fails the deploy, for example:
  - `function "my-function" input_query: metafields_query has 6 entries, max 5`
  - `function "my-function" input_query: metafields_query[0] resource "order" invalid, must be "product" or "shop"`
- The declaration takes effect when a merchant installs your app (see [Step 6](#step-6--binding-and-going-live)).

:::tip Keep the function's metafield small
A single metafield value can be up to 1 MB. Keep the one your function reads under about 10 KB, because it is loaded on every run.

When the same data is needed both to render the product page and to price the cart, split it into two metafields: one for the storefront to render, which can be large, and one for the function, holding only the fields the function reads. Declare only the second one. The function input stays small, and changing what the page shows never touches the function.
:::

### Output: the pricing operations

Write this JSON to `stdout`:

```jsonc
{
  "operation": {
    "update": [
      {
        "id": "line-id",                          // must match a cart line id
        "price": { "adjustment_fixed_price": "10.00" },   // final unit price = 19.90 + 10.00 = 29.90
        "title": "T-Shirt (custom)"               // optional
      }
    ]
  }
}
```

- `adjustment_fixed_price` is the amount added to the line's original unit price (non-negative, ≤ `999999999`, 2 decimals).
- `title` is optional (≤ 255 chars).
- Only the `update` operation is recognized; other keys are ignored.

### Reference entry file

This example marks up each product by the value of its `custom.markup` metafield, declared as in [Declare the metafields your function reads](#declare-the-metafields-your-function-reads). A product priced 100 with `custom.markup = 5` ends up at 105, and its checkout title becomes `<title> (+5)`. Lines without the metafield are left unchanged.

```javascript
// extensions/my-function/src/index.js

const input = readInput();
const result = run(input);
writeOutput(result);

// Mark up each line by the product metafield custom.markup. For lines that have it,
// output adjustment_fixed_price = markup; the platform adds it to the original price
// (final unit price = original price + markup) and the title shows the markup amount.
// Lines without the metafield are not output and keep their original price.
// The metafield must be declared in [[metafields_query]] in shoplazza.extension.toml,
// otherwise it is not included in the function input.
function run(input) {
  const update = [];

  input.cart.line_items.forEach((lineItem) => {
    const metafields = lineItem.product.metafields || [];
    const markupField = metafields.find(
      (metafield) => metafield.namespace === "custom" && metafield.key === "markup"
    );
    if (!markupField) {
      return;
    }

    const markup = parseFloat(markupField.value);
    // adjustment_fixed_price must be between 0 and 999999999: one invalid line rejects the whole output
    if (!Number.isFinite(markup) || markup < 0 || markup > 999999999) {
      return;
    }

    update.push({
      id: lineItem.id,
      price: {
        adjustment_fixed_price: markup.toFixed(2),
      },
      title: `${lineItem.product.product_title} (+${markup})`,
    });
  });

  return { operation: { update } };
}

function readInput() {
  const chunkSize = 1024;
  const inputChunks = [];
  let totalBytes = 0;
  // Read all the available bytes
  while (1) {
    const buffer = new Uint8Array(chunkSize);
    // Stdin file descriptor
    const fd = 0;
    const bytesRead = Javy.IO.readSync(fd, buffer);
    totalBytes += bytesRead;
    if (bytesRead === 0) {
      break;
    }
    inputChunks.push(buffer.subarray(0, bytesRead));
  }
  // Assemble input into a single Uint8Array
  const { finalBuffer } = inputChunks.reduce(
    (context, chunk) => {
      context.finalBuffer.set(chunk, context.bufferOffset);
      context.bufferOffset += chunk.length;
      return context;
    },
    { bufferOffset: 0, finalBuffer: new Uint8Array(totalBytes) }
  );

  return JSON.parse(new TextDecoder().decode(finalBuffer));
}

// Write output to stdout
function writeOutput(output) {
  const encodedOutput = new TextEncoder().encode(JSON.stringify(output));
  const buffer = new Uint8Array(encodedOutput);
  // Stdout file descriptor
  const fd = 1;
  Javy.IO.writeSync(fd, buffer);
}
```

### Validation and failure behavior

- The platform validates **all** operations before applying **any** — it is all-or-nothing. If one line fails validation (e.g. a negative or oversized price, a title over 255 chars, or an unknown line id), the whole output is rejected.
- Each bound endpoint has a **`block_on_failure`** flag:
  - `true` → a function failure **blocks** the cart/checkout action with an error.
  - `false` → the function result is **discarded** and the flow continues with unmodified prices.

### Keep it compilable and fast

Javy compiles a focused subset of JavaScript. For reliable, in-budget runs:

- Keep the entry self-contained in `src/index.js`; bundle any dependency into that one file.
- Avoid Node.js/browser APIs (`fs`, `fetch`, `window`, `document`, timers). Only `stdin`/`stdout` are available.
- Do arithmetic and return quickly. The engine enforces hard limits:

| Limit | Value |
|-------|-------|
| Instruction budget (fuel) | ~11,000,000 |
| Linear memory | 10 MB |
| Per-execution time | ~300 ms |
| Input JSON size | 10 MB |
| Output JSON size | 20 MB |
| Compiled WASM size | 2 MB |
| Single metafield value | 1 MB |

---

## Step 3 — Compile to WASM

Compile locally (no login, nothing uploaded) to catch build errors early:

```shell
shoplazza app function compile --name my-function
```

This runs `javy build extensions/my-function/src/index.js` and writes:

```
app-deploy/my-function.<md5-of-index.js>.wasm
```

A syntax error or unsupported API fails here with the Javy error.

---

## Step 4 — Test locally

Start the dev server and exercise the storefront:

```shell
shoplazza app dev
```

Follow the terminal prompts (enter your test store domain, set the App URL / Redirect URL in Partner Admin, open the Install URL). Then, in the storefront:

1. **Add a product to cart** → confirm the price/line total reflect your function.
2. **Start checkout** → confirm the adjusted price carries into checkout.

---

## Step 5 — Publish the function

You can publish a single function, or the whole app.

### Publish one function

```shell
shoplazza app function release --name my-function
```

`release` compiles and uploads the function to the active app:

- **First publish (create)** — registers a new function under the `cart_transform` namespace against your app's `client_id`, returns its `function_id` and `version`.
- **Subsequent publishes (commit)** — reuses the recorded `function_id`, bumps the **patch** version, and commits a new version. Versions are semantic (`MAJOR.MINOR.PATCH`) and must be **strictly ascending** — the backend rejects a non-increasing version with `Version not match`. The CLI handles the bump and writes the authoritative values back:

```toml
id = "1234567890123456789"
name = "my-function"
type = "function"
version = "1.0.1"
```

### Publish the whole app

To build and publish every extension (theme, checkout, function) together and cut a new **app version**:

```shell
shoplazza app deploy
```

`app deploy` writes back only the function `id` to `shoplazza.extension.toml`; it does not write back `version`.

Use `function release` while iterating on one function; use `app deploy` to release a full app version.

:::note
The `[[metafields_query]]` declaration is submitted with `app deploy` (and `app dev`). A standalone `function release` does not submit it.
:::

### List published functions

```shell
shoplazza app function list
```

---

## Step 6 — Binding and going live

For a published function to run in a store, it must be **bound** to that store. Binding happens **automatically** — you do not have to call anything for the basic case.

### Binding is automatic on install

When a merchant installs your app, the platform binds your **published** function to their store with default settings: failure does **not** block the buyer (`block_on_failure: false`), and the metafields declared in [`[[metafields_query]]`](#declare-the-metafields-your-function-reads) are loaded. From then on the function runs on add-to-cart, cart updates, and checkout (create/change/validate) for that store. When the app is uninstalled, the platform unbinds it automatically.

This means: **publish your function (Step 5), then just install/publish your app** — no extra binding call is required for it to take effect.

:::warning Publish before installing
- The binding is created at install time, so the function must be released with `app deploy` **before** the app is installed on a store. A store that installed the app before the function was published does not run it.
- A store that already has your app installed keeps running the function version it was bound to. Publishing a new function version does not switch it over, and updating the binding's config with `PATCH` does not either. To run the new version on a test store, uninstall and reinstall the app.
- Reinstalling creates a new binding with a different binding `id`.
:::

### Optional: customize or manage the binding (OpenAPI)

Use the Cart Transform OpenAPI only when you need to go beyond the defaults — block on failure, change the metafield declaration, or inspect/override bindings:

| Method & path | Purpose |
|---------------|---------|
| `GET /openapi/2025-06/function/cart-transform` | List the store's bindings |
| `POST /openapi/2025-06/function/cart-transform` | Bind a function (or re-bind with custom config) |
| `PATCH /openapi/2025-06/function/cart-transform/{id}` | Change the function or its config |

Call it with the store's access token: `Access-Token: {token}`, host `https://{store_domain}`. Request body:

```jsonc
{
  "function_id": "1234567890123456789",   // the function id written back to shoplazza.extension.toml
  "block_on_failure": false,               // true = block cart/checkout if the function errors
  "input_query": "{\"metafields_query\":[{\"resource\":\"product\",\"namespace\":\"custom\",\"key\":\"markup\"}]}"
}
```

- `block_on_failure`:
  - `true` — a function failure makes the add-to-cart request fail. The storefront receives a generic error (`{"state":"server_error","message":"Service error, please try again later"}`); the function's own error details are not passed through.
  - `false` (the install default) — the function result is discarded and the product is added at its original price.
- `input_query` — optional JSON string declaring which metafields to load into your function input, in the same format the CLI submits from `[[metafields_query]]`. Up to 5 entries; more returns `metafields query max limit is 5`. On `PATCH`, an empty string or an omitted `input_query` keeps the current declaration.

`GET` returns each binding's `id`, `function_id`, and `block_on_failure` under `data.cart_transforms`, plus `data.count`. It does not return `input_query`.

:::note For apps with their own backend
If your app is hosted on your own backend (not the CLI dev tunnel), set your App URL / Redirect URL on the Partner dashboard (`shoplazza app config push`) and handle OAuth there. **Function binding still happens automatically on install** — it is tied to the install event, not to `shoplazza app dev`.
:::

### Going live checklist

Because function development is part of app development, going live follows the normal app lifecycle:

1. ✅ `function release` (or `app deploy`) succeeds and sets the version live; `shoplazza app function list` shows the function and version.
2. ✅ App has a deployed version (`shoplazza app versions`) and the `write_cart_transform` scope.
3. ✅ Verified on a development/test store: cart and checkout show the adjusted prices.
4. ✅ Metafields your function reads are declared in `[[metafields_query]]` and released with `app deploy`. (Optional) If you need to block on failure, your app calls the Cart Transform OpenAPI to set `block_on_failure`.
5. ✅ Submit the app for review and publish it, per [Submit for review](/docs/app/getting-started/create-public-app/submit-review). After approval, each merchant who installs your app gets the function bound and live automatically.

---

## Configuration reference

### `shoplazza.extension.toml`

| Field | Set by | Description |
|-------|--------|-------------|
| `name` | you (via `--name`) | Extension name and directory under `extensions/` |
| `type` | scaffold | Always `"function"` |
| `id` | first publish | Remote `function_id`; drives create-vs-commit on the next release |
| `version` | `function release` | Last published version, written back by `function release` (`app deploy` does not write it back); used to compute the next bump |
| `[[metafields_query]]` | you | Metafields the function reads: `resource` (`product` or `shop`), `namespace`, `key`. Up to 5 entries. See [Declare the metafields your function reads](#declare-the-metafields-your-function-reads) |

### `shoplazza.app.toml`

| Field | Description |
|-------|-------------|
| `scopes` | Must include `write_cart_transform` (add `read_cart_transform` to read cart data) |

## Command reference

| Command | What it does |
|---------|--------------|
| `shoplazza app extension create --type function --name <name>` | Scaffold a new function extension |
| `shoplazza app function compile --name <name>` | Compile `src/index.js` to WASM locally (no publish) |
| `shoplazza app function release --name <name>` | Compile and publish a single function |
| `shoplazza app function list` | List the active app's function extensions |
| `shoplazza app deploy` | Build and publish the whole app, cutting a new version |

Full flag reference: **[`app` command reference](/docs/app/developer-tools/shoplazza-cli/commands/app)**.

## Troubleshooting

- **`extension "my-function" already exists at …`** — a directory with that name already exists under `extensions/`. Pick another name, or remove the old directory.
- **`function entry not found`** — `src/index.js` is missing under `extensions/<name>/`. Check `--name`, or re-scaffold.
- **`javy build failed`** — a syntax error or unsupported API in `src/index.js`. Any Javy stderr fails the build, even on exit 0. Simplify and recompile.
- **`invalid --name`** — the name has path separators or illegal characters. Use `^[a-z0-9][a-z0-9_-]*$`.
- **`Version not match` on release** — the toml `version` is out of sync with the backend. Run `function list` and align it.
- **`input_query: metafields_query has 6 entries, max 5` or `resource "order" invalid` on `app deploy`** — the `[[metafields_query]]` declaration has more than 5 entries, or a `resource` other than `product` / `shop`. Fix the toml and deploy again.
- **Store price unchanged after you publish a new function version** — a store that already has the app installed keeps its existing binding. Uninstall and reinstall the app on the test store, then remove the old line from the cart and add the product again.
- **Price not changing in the store** — confirm the app is installed, the `cart_transform` endpoint is bound for that store, and (if your output is silently dropped) that validation passed and `block_on_failure` behavior is what you expect.
- **Function exceeds limits** — if you hit the fuel/time/output caps, reduce per-line work and output only the lines you actually change.

## FAQ

**Does one function cover both cart and checkout?**
Yes. Publish once; the same function runs on the cart page (add/update) and at checkout creation.

**Can a function raise *or* lower prices?**
It can only raise prices or keep them unchanged. `adjustment_fixed_price` must be non-negative and is added to the original price, so a function cannot lower a price. To offer a price reduction, use discounts.

**Can a function apply order-level or shipping discounts?**
No — `cart_transform` only changes **line price and title**. Order/shipping-level discounting is handled by separate discount mechanisms outside the `cart_transform` namespace.

## Next steps

- **[`app` command reference](/docs/app/developer-tools/shoplazza-cli/commands/app)** — every flag for the function subcommands.
- **[Build your first app](/docs/app/developer-tools/shoplazza-cli/tutorials/build-your-first-app)** — the end-to-end app workflow.
- **[Submit for review](/docs/app/getting-started/create-public-app/submit-review)** — publishing your app to merchants.
- **[Add a per-product surcharge with a function](/docs/app/use-cases/product-merchandising/add-surcharge-by-metafield)** — a complete example that prices cart lines from a product metafield.
