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.
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 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.
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.
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 installed and authenticated (
shoplazza auth login). -
An app project created with
shoplazza app init. See Build your first app. -
The right scopes in
shoplazza.app.toml. Cart-transform needswrite_cart_transform(addread_cart_transformto read cart data). The default app template already ships with:tomlscopes = "read_customer write_cart_transform"
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:
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:
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):
{
"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). 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:
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(andapp 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 5function "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).
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:
{
"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_priceis the amount added to the line's original unit price (non-negative, ≤999999999, 2 decimals).titleis optional (≤ 255 chars).- Only the
updateoperation 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. 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.
// 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_failureflag: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). Onlystdin/stdoutare 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:
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:
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:
- Add a product to cart → confirm the price/line total reflect your function.
- 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
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_transformnamespace against your app'sclient_id, returns itsfunction_idandversion. - 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 withVersion not match. The CLI handles the bump and writes the authoritative values back:
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:
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.
The [[metafields_query]] declaration is submitted with app deploy (and app dev). A standalone function release does not submit it.
List published functions
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]] 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.
- The binding is created at install time, so the function must be released with
app deploybefore 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
PATCHdoes 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:
{
"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 returnsmetafields query max limit is 5. OnPATCH, an empty string or an omittedinput_querykeeps 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.
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:
- ✅
function release(orapp deploy) succeeds and sets the version live;shoplazza app function listshows the function and version. - ✅ App has a deployed version (
shoplazza app versions) and thewrite_cart_transformscope. - ✅ Verified on a development/test store: cart and checkout show the adjusted prices.
- ✅ Metafields your function reads are declared in
[[metafields_query]]and released withapp deploy. (Optional) If you need to block on failure, your app calls the Cart Transform OpenAPI to setblock_on_failure. - ✅ Submit the app for review and publish it, per Submit for 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 |
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.
Troubleshooting
extension "my-function" already exists at …— a directory with that name already exists underextensions/. Pick another name, or remove the old directory.function entry not found—src/index.jsis missing underextensions/<name>/. Check--name, or re-scaffold.javy build failed— a syntax error or unsupported API insrc/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 matchon release — the tomlversionis out of sync with the backend. Runfunction listand align it.input_query: metafields_query has 6 entries, max 5orresource "order" invalidonapp deploy— the[[metafields_query]]declaration has more than 5 entries, or aresourceother thanproduct/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_transformendpoint is bound for that store, and (if your output is silently dropped) that validation passed andblock_on_failurebehavior 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
appcommand reference — every flag for the function subcommands.- Build your first app — the end-to-end app workflow.
- Submit for review — publishing your app to merchants.
- Add a per-product surcharge with a function — a complete example that prices cart lines from a product metafield.