Store functions

Your own code in the cart, the checkout and the order: six hooks, what each answers, what happens when it fails, shadow mode, versions and what it costs.

A store function is a small piece of JavaScript you — or your agent — write, which Formahand runs at a named point inside your store: adding a free gift to a cart, pricing a cart, checking a discount, narrowing the shipping rates, refusing a checkout, reacting to an order. It is how a store gets behaviour the rule builder cannot express: "buy three mugs, get ten percent off", "this code is not for wholesale accounts", "hide express shipping for fragile orders", "no hazardous goods to PO boxes", "tag every order over $500 and email me".

There are seven hooks. Each one is handed JSON, answers JSON, and has a declared behaviour when it does not answer — which is a property of the hook and never yours to choose.

When Formahand has no feature for what you want, you build it as store code. A free gift, a bundle, a loyalty discount, a personal code after purchase, a quantity break: a function decides, an app keeps records and routes, and a section's isolated behavior presents it to shoppers through the bounded bridge. Existing owner-authored legacy section scripts remain a separate, more powerful same-origin option; agents should use behavior for new interactions. The worked examples in §6 use Formahand's store tools.

Functions run on test stores freely. On a live store they are gated three ways, and the third is the important one: a token can put a function into shadow mode on live traffic, and only a person can take it out.

A function is one answer to one question inside a request. If what you want is a whole feature — its own addresses on your store, its own records, its own settings, something on a page — that is an app, and an app can register functions of its own.

1. What a function is

One JavaScript file. It exports one object with one method — the one its hook names, or run, which every hook accepts:

export default {
	price(input) {
		return { version: 1, lines: [] };
	},
};

That is the whole shape. input is JSON describing the situation; the return value is JSON describing what you want. Nothing else is called, and nothing else is available.

The sandbox has nothing in it. Your function runs with no network, no storage, no imports and no clock of its own:

You might reach forWhat actually happens
fetch(…), new WebSocket(…)Not defined. The sandbox has no network at all, and an attempt to make one anyway is refused before it leaves Formahand.
connect(…), a raw socketRefused. Nothing reaches the network, though the refusal may reach you as a socket that never opens rather than as an error on the call.
import anythingYour upload is refused if it contains an import. A function is a single file; bundle what it needs into that file before you upload it.
caches, storage of any kindNot defined.
Date.now(), new Date()Pinned to input.at for the whole invocation. It does not advance.
Math.random()Seeded from input.at, so the same input gives the same answer twice.
crypto, setTimeout, processNot defined.
Remembering something for next timeDon't. A function may happen to find a value it left on globalThis on a later call, because the sandbox reuses warm ones — but it is thrown away without warning and it is not shared between the machines your store runs on. A function that needs state is a function that will give two shoppers two different answers.
Anything of the store's or of Formahand'sThere is nothing there. The only thing your function is handed is input, and the only thing it can change is what it returns.

This is not a restriction you work around; it is the reason a function is allowed near a checkout at all. Everything your function needs must be in input or written into the file.

The sandbox was red-teamed on 2026-09-23 — twenty-one hostile functions, uploaded and run for real against a live store. Every one of them failed to reach anything outside its own input.

Two more hard limits:

  • the file is at most 64 KB;
  • what you return is at most 16 KB of JSON.

2. The hooks

HookMethodRuns atIt mayIf it does not answer
cart.transformtransformthe cart, the cart JSON (/__fh/cart.json) and the checkout quoteadd catalogue lines to the cart (a free gift, a bundle's parts), set line attributes, show offersOpen: the cart stays exactly as the shopper built it
cart.pricepricethe cart and the checkout quotelower the price of lines in the cart, take an amount off the order, lower shipping, show offersOpen — the cart keeps the price your own rules produced
discount.eligibilityeligiblewhen a code or automatic discount would applyallow or veto that discount for this cartClosed — the discount does not apply; the cart still checks out, at list
shipping.optionsoptionsthe checkout, once real rates existhide, rename and reorder the rates your store producedOpen — every rate is offered, unchanged
payment.methodsmethodsthe checkouthide payment methods your store offersOpen — every method is shown
checkout.validatevalidatethe checkout, before anything is writtenblock the order with a sentence the shopper readsOpen — the order goes through and is flagged
order.eventeventafter the order is written, out of bandask for up to ten actions, including making a code and emailing itOpen — the order is unaffected and the run is retried
thankyou.contentcontentthe order confirmation page, after your own thank-youshow up to three offers (a product for a new cart, or a code for next time) and one short messageOpen: the thank-you page shows what it always does

Call describe_store_functions for the same table with a sample input for each hook.

A hook with no function behaves exactly as it did before functions existed.

Several functions on one hook

A hook takes several functions: yours, and the ones an app brought. They run in position order, lower first, ties by name; set it with create_store_function { position } or enable_store_function { position } (your own default to 0, an app's to 100, so yours run first unless you say otherwise). At most 3 run on a hook a shopper waits for, and 5 on order.event.

How they combine is a property of the hook:

HookSeveral functions
cart.transform, cart.price, shipping.options, payment.methodsIn order, each handed what the one before left: the lines it added, the prices it set, the rates it kept
discount.eligibilityEvery one is asked; any one saying no refuses the discount, and the first reason is the one the shopper reads
checkout.validateIn order until one refuses
order.eventAll of them run on the event, each with its own ten actions; a retry never repeats the actions of one that already acted
thankyou.contentAll of them run on the same order; their offers are shown in position order, three in all, and the first message wins

Each function is clamped against what it was handed, so together they can do nothing one of them may not. A shadow function in a chain runs and is recorded, and the next function sees the cart as if it had not run. Every function counts separately against your live allowance (§10).

Why discount.eligibility is the one that fails closed

Everywhere else, "open" means the shopper gets what your store would have given them anyway. Here it would mean giving money away because a function timed out. A shopper who does not get a discount is disappointed; a store that discounts every cart because of a typo is being robbed. So a throw, a timeout, an invalid answer and an explicit eligible: false all land in the same place: the code does not apply, and the shopper is told why in your function's own words.

Why checkout.validate fails open, and what to use instead

This is the contested one and the answer is deliberate. Failing closed would turn one broken function into a store that cannot take a single order — the worst outcome this feature can produce. Failing open takes an order you might have refused, which you can cancel and refund. Recoverable beats unrecoverable.

So: if you need a hard block — age verification, export control, a country you may not ship to — use a checkout rule. A rule is data, we evaluate it, and it cannot fail. A checkout.validate function is for the softer kind: "this B2B order needs a PO number", "we don't ship aerosols to PO boxes".

When a validation function does not answer, the order is taken and carries validation_skipped in its record of what applied, so you can find it.

3. The input, hook by hook

Every input carries the same five fields: version, hook, mode (live or test), storeId and at (an ISO timestamp, frozen for the invocation). Every amount is in minor units — cents for USD, pence for GBP, yen for JPY, where a yen is the minor unit, so ¥1500 is 1500. There are no decimals anywhere in this contract, on purpose: one integer per line is what keeps the cart, the payment page and the order in step with nothing to reconcile afterwards.

The input is deeply frozen. Writing to it does nothing.

The cart, shared by five hooks

cart: {
	lines: Array<{
		lineId: string;              // what you name in your answer
		productId: string;
		variantId: string | null;
		sku: string | null;
		quantity: number;
		listPriceMinor: number;      // the catalog price, per unit
		unitPriceMinor: number;      // the price this line has reached, per unit
		costMinor: number | null;    // your recorded cost, per unit, when you keep one
		collectionHandles: string[];
		tags: string[];
	}>;
	subtotalMinor: number;
}
customer: { id: string | null; groupHandle: string | null; tags: string[]; firstOrder: boolean } | null;
destination: { country: string; postalCode: string | null } | null;

unitPriceMinor is the price the line has already reached: your function runs after customer groups, discount codes and your own pricing rules, on what those left behind. listPriceMinor is the catalog price, for when you want to reason about the original.

Added 2026-09-27, and present only when there is something to say (a function written against the shape above keeps working unchanged):

// each line
productSlug?: string;                    // the handle the storefront uses
fields?: Record<string, unknown>;        // the product's and the variant's custom fields, "namespace.key"
attributes?: Record<string, string>;     // line attributes (a gift message, a bundle id)
addedBy?: string | null;                 // the cart.transform function that added it
// customer (only for a shopper the store verified: signed in, or a claim it signed)
signedIn?: boolean;
orderCount?: number;
totalSpentMinor?: number;
fields?: Record<string, unknown>;        // the customer's custom fields in namespace "functions", and only those
// the code the shopper typed
code?: { typed: string | null; known: boolean; applied: boolean };

A product's custom fields are how a section and a function share data: a template prints pricing.breaks, and the cart.price function that gives the discount reads the same field. Customer fields other than the functions namespace may be personal and never reach a function; an app that keeps a loyalty balance writes it to functions.points for the function to read.

cart.transform

The one hook that changes what is in a cart. Adds declined (the items the shopper removed after a function added them) to the shared fields. Answers:

{
	version: 1;
	actions?: Array<
		| { type: "add_line"; productId?: string; variantId?: string; quantity: number;   // 1 to 10
		    gift?: boolean; required?: boolean; label?: string; attributes?: Record<string, string> }
		| { type: "set_line_attributes"; lineId: string; attributes: Record<string, string | null> }
	>;
	offers?: Offer[];   // see below
}

add_line names a product or variant id and a quantity, never a price. The line is priced from your catalogue and then only ever down. An item already in the cart is covered rather than doubled: gift: true then makes that many of its units free. A line you add is removable unless it is a free gift you mark required, and one the shopper removed is never added back. It runs on every read of the cart, so nothing it decided is stored: a shopper cannot edit a cookie into a free gift. At the checkout the lines are already fixed, so there it only covers the lines the cart page added.

Offers are what the storefront shows, never what it charges: { id, kind: "threshold" | "quantity_break" | "gift" | "bundle" | "custom", label, detail?, thresholdMinor?, quantity?, amountMinor?, percent?, productIds?, endsAt? }. The store works out how far the cart is (remainingMinor, progress, met) from the discounted subtotal, so a progress bar cannot say more than the cart does. cart.price may answer offers too.

cart.price

Adds nothing beyond the shared fields and currency. Answers:

{
	version: 1;
	lines?: Array<{ lineId: string; unitPriceMinor: number; reason?: string }>;
	note?: string;
	orderDiscounts?: Array<{ label: string; amountMinor: number }>;          // at most 3
	shippingDiscount?: { label: string; percent?: number; amountMinor?: number };
	offers?: Offer[];
}

A line you do not name is left alone. An order discount is its own line on the cart and the order, under your label, spread over the lines in whole minor units so the payment page charges what the cart shows. A shipping discount lowers every rate the payment page offers, by a percent or an amount, never below zero.

discount.eligibility

Adds the discount the store has already decided applies, and what it is worth:

discount: {
	kind: "code" | "automatic";
	id: string | null;
	code: string;              // as typed, upper-cased; empty for an automatic rule
	name: string;
	amountMinor: number;       // what it would take off this cart
	freeShipping: boolean;
}

Answers { version: 1, eligible: boolean, reason?: string }. When eligible is false the reason is shown to the shopper, so write it for them.

shipping.options

rates: Array<{ id: string; name: string; amountMinor: number; minDays: number | null; maxDays: number | null }>;
origin: { country: string; postalCode: string | null } | null;

Answers:

{ version: 1; rates?: Array<{ id: string; hidden?: boolean; label?: string; sort?: number }> }

Note what is missing: there is no price field. A function narrows and renames an offer; it cannot make one. The id is stable within one invocation and is the only way to name a rate.

payment.methods

methods: Array<{ id: string; name: string }>, answered with { version: 1, methods?: Array<{ id: string; hidden?: boolean }> }. Same rule: hide, never add.

checkout.validate

Adds the final totals, the addresses and the shopper:

totals: { subtotalMinor; discountMinor; shippingMinor; taxMinor; totalMinor };
shippingAddress: { name; line1; line2; city; region; postalCode; country } | null;
billingAddress: same | null;
email: string | null;
shippingRateName: string | null;
paymentMethodId: string | null;

Answers { version: 1, ok: true } or { version: 1, ok: false, message: "…" }. A refusal must carry a message — a shopper who is told "no" and not "why" has nothing to do about it — and the message is escaped before it reaches a page, so markup in it renders as characters.

order.event

Fires on order.created, order.paid, order.fulfilled, order.delivered, order.refunded and order.cancelled, and on a return: return.requested, return.approved, return.received, return.refunded. After the write, out of band. A return event hands you the return's order, plus return: { id, status, reason }.

event: { id: string; type: string; time: string };
order: {
	id; number; status; placedAt;
	subtotalMinor; discountMinor; shippingMinor; taxMinor; totalMinor;
	tags: string[]; customerId: string | null; email: string | null;
	destination: { country; postalCode } | null;
	lines: Array<{ productId; variantId; sku; title; quantity; unitPriceMinor }>;
};
templates: string[];      // the email templates your store owns
subscriptions: string[];  // the event subscriptions your store has
codeTemplates?: string[]; // the discount codes your store keeps as templates

Answers a list of actions, which we perform:

{ version: 1; actions: Array<
	| { type: "add_tag"; tag: string }
	| { type: "add_note"; note: string }
	| { type: "send_email"; template: string; to: "buyer" | "merchant" }
	| { type: "call_webhook"; subscription: string }
	| { type: "wake_agent"; reason?: string }
	| { type: "mint_code"; template: string; expiresInDays?: number; bindToBuyer?: boolean }
	| { type: "tag_customer"; tag: string }
> }

mint_code makes one single-use code from one of your template codes (see codes made from a template); an email in the same answer carries it as {{ issued.code }}, with {{ issued.link }} a link that applies it. That is how a post-purchase offer reaches a buyer with a code of their own. A function makes at most 200 codes a day and the store 2,000, unless the owner changed those limits (set_code_limits, up to 50,000). tag_customer tags this order's own customer.

At most ten actions, at most one that sends mail, and at most one code. That is refused at validation, not trimmed at execution — a function that means to send two emails fails on its first run instead of quietly sending one for ever.

Three things an action cannot carry, and each is deliberate:

  • an order id. Actions apply to the order whose event you were handed. There is no field for another one, so acting on somebody else's order is not refused — it is unsayable.
  • an email body. template names one of the templates in your input, which are your own templates, in your own voice, sent from your own verified sender and counted on your own email allowance.
  • a URL. call_webhook names a subscription you already made. wake_agent names nothing at all: it wakes the agent subscriptions you already have.

thankyou.content

Runs when the shopper lands on the order confirmation page after paying. You are handed the order behind that checkout: its lines and totals, never an email, an address or a card.

order: {
	id; number: number | null; subtotalMinor; totalMinor; discountCode;
	lines: Array<{ productId; variantId; quantity; unitPriceMinor }>;
};
customer: { id; orderCount } | null;  // orderCount: paid orders, this one included

Answers up to three offers and one message, which the store draws itself under the thank-you, in the store's typeface:

{ version: 1;
  message?: string;                          // one line, 200 characters
  offers?: Array<{
    id: string;                              // lowercase, digits, - and _
    title: string;                           // 80 characters
    text?: string;                           // 200 characters
    add?: { productId?; variantId?; quantity?; label? };  // a product for a new cart
    code?: string;                           // a code of yours for next time
  }> }

An offer is add or code, never both. **add** puts a catalogue item in a new cart with one click: the order just paid for is closed, so nothing is charged, no saved card is used, and the shopper pays for it on a new checkout at the catalogue price. An id that is not an active, in-stock item of your store drops the offer (unknown-product); a product with options needs its variantId. **code** shows one of your codes with a link that applies it; a code that is not active now, a template, or one bound to somebody else's address drops the offer (unknown-code). To give every buyer a code of their own, mint it with order.event on order.paid and email it (below); this hook shows codes that already exist.

Everything you write is plain text, escaped. The page is the shopper's own and never cached.

4. What the store does to your answer

Formahand does not take your answer on trust. Every hook clamps, and every override is recorded where you can read it.

Prices (cart.price) go through four rules before a single minor unit moves:

  1. A price is a whole number of minor units. A fraction is rounded up.
  2. You cannot raise a price. A price above the one the line already had is discarded. A pricing function is a way to take money off; it is not a way to add a fee. (Fees belong in checkout settings, where the store computes them — see §5.)
  3. Never below zero.
  4. Never below cost. When the product or variant carries a recorded cost, that is the floor — and a line already at or under its cost cannot be discounted at all.

A line the cart does not have is ignored; naming the same line twice counts once.

Rates and methods may only be narrowed. An id you were not given is ignored. And narrowing to nothing is refused: if your answer would leave the checkout with no shipping option or no payment method at all, the whole answer is discarded and your store's own list stands. A checkout nobody can complete is the one thing this feature may never produce.

Actions may only name a template, a subscription or a code template your store already has — the ones listed in your input.

Lines (cart.transform) may only be your own active, published products and variants, in stock or on backorder; every function together adds at most 3 lines and 10 units to one cart (over-lines, over-units); an item the shopper turned down is not added (declined); required holds only on a free gift (required-not-gift); at the checkout nothing is appended (not-at-quote). Attributes are plain text, at most 5 per line and 80 characters each, and never overwrite one the shopper set.

Order and shipping discounts (cart.price) never take a line below its floor (order-floor) and never raise a rate.

Gifts below cost. The cost floor has one exception, and it is yours to set: set_gift_exception { subject: "function:<name>", productId, allowed: true } lets exactly that function take exactly that product to zero. It is recorded in your event log with who set it, and an order priced through one says so. A pricing rule or a code can carry one too (rule:<id>, code:<id>). Without it, a gift with a recorded cost stops at its cost.

These are the same two promises the rule builder makes and they are not negotiable: a rule, or a function, may never invent a shipping rate the store did not configure, and may never take a line below zero or below cost.

5. What happens when it fails

Nothing stops the sale. With one deliberate exception — a checkout.validate function that explicitly answers ok: false — every failure path leads back to the store behaving as if there were no function: the cart keeps its price, the rates stand, the methods stand, the order is taken, the event run is retried. That covers a throw, a timeout, invalid output, a deleted function, a function over its budget, and a store past its monthly allowance.

The reason is recorded on the function, where you and the merchant can read it: list_store_functions shows the last failure, how many times it has failed, and the p50/p95 of recent runs.

That decision has one consequence worth understanding before you design anything: a pricing function is a discount mechanism, not a surcharge mechanism. A store that sometimes forgets to charge a fee is worse than a store that sometimes forgets to give a discount, so a function may only ever take money off (§4, rule 2), and a mandatory fee belongs in checkout settings where the store computes it.

The budgets a function runs under:

BudgetValue
Time to answer50 ms of wall clock
Processing time10 ms
Calls outnone; there is no network
Code64 KB
Answer16 KB

Going over any of them is a failure like any other, which means the fallback.

A function that fails twenty times in a row is switched off automatically, with the reason on its row. That counter resets the moment it answers cleanly.

6. Worked examples, one per hook

cart.transform: a free tote over $50

The whole of a free-gift-with-purchase offer, as store code:

const TOTE = "tote"; // your product's id (list_products)
const THRESHOLD_MINOR = 5000;

export default {
	transform(input) {
		// Everything but the tote counts: a gift never qualifies its own cart.
		const spent = input.cart.lines
			.filter((line) => line.productId !== TOTE)
			.reduce((total, line) => total + line.unitPriceMinor * line.quantity, 0);
		const offer = { id: "free-tote", kind: "gift", label: "Free tote over $50", thresholdMinor: THRESHOLD_MINOR, productIds: [TOTE] };
		if (spent < THRESHOLD_MINOR) return { version: 1, offers: [offer] };
		return {
			version: 1,
			actions: [{ type: "add_line", productId: TOTE, quantity: 1, gift: true, label: "Free tote" }],
			offers: [offer],
		};
	},
};

If the tote has a recorded cost, run set_gift_exception { subject: "function:free-tote", productId: "tote", allowed: true } first. The cart shows the tote with "Free tote" beside it at no charge, the checkout charges nothing for it, and the offer drives a progress bar until the cart qualifies.

cart.price — buy 3 mugs, get 10% off

/**
 * 10% off drinkware once the cart holds three or more of it.
 * Counts units across every drinkware line, not lines.
 */
const COLLECTION = "drinkware";
const THRESHOLD = 3;
const PERCENT = 10;

export default {
	price(input) {
		const drinkware = input.cart.lines.filter((line) =>
			line.collectionHandles.includes(COLLECTION),
		);
		const units = drinkware.reduce((total, line) => total + line.quantity, 0);
		if (units < THRESHOLD) return { version: 1, lines: [] };
		return {
			version: 1,
			lines: drinkware.map((line) => ({
				lineId: line.lineId,
				// Round the new price UP, so the discount is never more than 10%.
				unitPriceMinor: Math.ceil(line.unitPriceMinor * (1 - PERCENT / 100)),
				reason: `${PERCENT}% off ${COLLECTION} (${units} in the cart)`,
			})),
			note: `${PERCENT}% off ${COLLECTION}`,
		};
	},
};

To show the ladder on the product page, answer offers too ({ id, kind: "quantity_break", label, quantity, percent } per tier): a section behavior reads them for any product with api.cart.prices(["mug"]), so the table and the discount come from one function. Tiers that differ by product can live in a product custom field (pricing.breaks), which the function reads as line.fields["pricing.breaks"].

Three things worth copying: count units, not lines (three mugs in one line is three mugs); compute a price, not a discount (the contract asks what the line should cost, which is one number to get right instead of two); round the price up, so the discount rounds down — the only direction that cannot take off more than you said.

discount.eligibility — no stacking on wholesale

/** Wholesale accounts already have their price list; codes are for everyone else. */
export default {
	eligible(input) {
		if (input.customer?.groupHandle === "wholesale")
			return {
				version: 1,
				eligible: false,
				reason: "Trade accounts already get trade prices, so codes do not apply.",
			};
		return { version: 1, eligible: true };
	},
};

shipping.options — no express on heavy carts

/** Over 20 units, express is a promise we cannot keep: hide it and put standard first. */
export default {
	options(input) {
		const units = input.cart.lines.reduce((total, line) => total + line.quantity, 0);
		if (units <= 20) return { version: 1, rates: [] };
		return {
			version: 1,
			rates: input.rates.map((rate) =>
				/express|overnight/i.test(rate.name)
					? { id: rate.id, hidden: true }
					: { id: rate.id, sort: 0, label: rate.name },
			),
		};
	},
};

If every rate matched /express/i the answer would leave nothing to ship with, and the store would discard it and offer the full list. Narrowing to nothing never happens.

payment.methods — cash on delivery only near home

/** Bank transfer and cash on delivery are for local orders; cards are for everyone. */
export default {
	methods(input) {
		const local = input.destination?.country === "US";
		if (local) return { version: 1, methods: [] };
		return {
			version: 1,
			methods: input.methods
				.filter((method) => method.id === "manual")
				.map((method) => ({ id: method.id, hidden: true })),
		};
	},
};

checkout.validate — a PO number for trade accounts

/** Trade accounts are invoiced, and an invoice without a PO number is a phone call. */
export default {
	validate(input) {
		if (input.customer?.groupHandle !== "wholesale") return { version: 1, ok: true };
		const note = (input.shippingAddress?.line2 ?? "").trim();
		if (/^PO[- ]?\d{4,}/i.test(note)) return { version: 1, ok: true };
		return {
			version: 1,
			ok: false,
			message: "Trade orders need a PO number. Put it on the second address line, like PO-10432.",
		};
	},
};

Remember what this hook is for. If the block has to hold — age, export control, a country you may not ship to — write a checkout rule instead. A rule cannot time out.

order.event — ask for a review, once, a week after delivery

/** One review request per delivered order, and one tag so it is visible on the order. */
export default {
	event(input) {
		if (input.event.type !== "order.delivered") return { version: 1, actions: [] };
		if (input.order.tags.includes("review-requested")) return { version: 1, actions: [] };
		if (!input.templates.includes("review-request")) return { version: 1, actions: [] };
		return {
			version: 1,
			actions: [
				{ type: "send_email", template: "review-request", to: "buyer" },
				{ type: "add_tag", tag: "review-requested" },
			],
		};
	},
};

Two habits worth copying. Check the event type: one function sees all five. Check the tag before acting: the order's current tags are in your input, and a function that looks before it writes is a function that can be retried safely.

thankyou.content: the saucer that goes with the mug

/** Suggest the saucer to anyone who bought the mug without it. */
export default {
	content(input) {
		const bought = new Set(input.order.lines.map((line) => line.productId));
		if (!bought.has("p_mug") || bought.has("p_saucer")) return { version: 1 };
		return {
			version: 1,
			offers: [
				{
					id: "saucer",
					title: "The saucer that goes with it",
					text: "Add it to a new cart in one click.",
					add: { productId: "p_saucer", quantity: 1 },
				},
			],
		};
	},
};

7. The loop: build it with an agent

Nine tools, all on the storefront:write scope. Write scope even to read, because a listing carries your source, and there is no read-only door into somebody's code.

ToolDoes
describe_store_functions {}The hooks, what each is handed, what it may answer, what happens when it does not. Read this first.
create_store_function { name, hook, source, position? }Uploads the file as a new version and leaves the function switched off. Returns the static checks, including hints that are not errors. position orders it among the hook's functions.
test_store_function { name, input? }Runs it once against an input you supply, or a sample one, and returns what it asked for, what the store would apply, every clamp, and how long it took. Nothing is charged and the function need not be switched on.
enable_store_function { name, mode?, position? }on for a test store; on a live store a token may only choose shadow.
disable_store_function { name }Switches it off. Always allowed.
list_store_functions {}Every function, with counters and the last failure.
get_store_function { name, version? }One function in full: source, versions, counters, and its shadow results.
rollback_store_function { name, version }Points it back at an earlier version. Keeps the current status.
delete_store_function { name }Removes it. The versions are kept so the store can still answer "what was running on the twelfth".

The loop is: read describe_store_functions → write → create_store_function on the test store → test_store_function until the numbers are right → enable_store_function → place a test order → promote → shadow on live → the merchant switches it on.

Uploading the same name again adds a version and switches the function off, so a fix is never live before you have run it. Rollback is the exception: it keeps the status, because rollback is the lever you pull when something is wrong right now.

create_store_function runs a handful of static checks first, and it is worth knowing what they are for. They are not a security control — the sandbox is what makes the security true — they are there so that a function which calls fetch is refused at upload with a sentence about it, rather than quietly failing on a cart at three in the morning. Some come back as hints rather than errors: reading the clock is not wrong, it is just pinned, and it is cheaper to be told now.

Reading a test_store_function answer

{
  "hook": "cart.price",
  "outcome": "ok",
  "wallMs": 4,
  "applied": [
    { "lineId": "cart-0", "fromUnitPriceMinor": 1200, "toUnitPriceMinor": 1080,
      "unitDiscountMinor": 120, "reason": "10% off drinkware (3 in the cart)" }
  ],
  "clamps": [],
  "fellBack": false
}

clamps is the field to read first. An empty list means the store took your answer as given. An entry means it did not, and why — cost and at-cost mean you went under a recorded cost, raise means you asked for more than the line already cost, unknown-line and unknown-rate mean you named something that is not there, all-hidden means your answer would have left the checkout with nothing to offer. A function whose clamps list is never empty is a function that does not yet understand its own store.

8. Versions, shadow mode and rollback

Every upload is a new version, keyed by a hash of its contents, and a function is a pointer at one of them. get_store_function lists the last ten with who uploaded each and when. Rolling back moves the pointer: it takes effect immediately and rebuilds nothing.

Versions that are no longer active keep their sandbox for 30 days and then have it reclaimed. The source is kept for ever, so you can always read what ran — but after 30 days rolling back to it means uploading it again as a new version, and get_store_function says which versions can still be rolled back to.

Shadow mode is how a function earns a live store. In shadow, a function is invoked on real traffic, with the same input and the same budget it would have live, and its answer is recorded and thrown away: every hook takes its fallback exactly as if the function were not there. Then get_store_function tells you what it would have done — how many carts it would have changed, by how much on average, how many checkouts it would have refused, how many times it fell open.

For anything that touches a price, shadow mode should be where a function lands and stays for a week. The dashboard says so, and an agent that cannot yet quote those numbers should not be asking for the switch.

9. Test stores, live stores, and who may switch what

A function exists per environment. A version on your test store and a version on your live store are separate things, and promote_to_live copies a function across switched off.

On a test store: as many functions as you like, none of them counted, walled or billed, and no shopper ever sees them.

On a live store, three gates:

  1. Readiness — your storefront is serving, you have something to price, and payments are set up. describe_readiness lists the steps; the dashboard shows the same checklist.
  2. The allowance — your plan includes a number of live functions and a number of invocations a month (§10). Past the included invocations, the overflow is billed on your statement and needs a payment method; without one, nothing new is switched on.
  3. A person. A token may put a function into shadow mode on a live store and may never take it out. Moving a function from shadow to live is done in the dashboard, under Online Store → Apps & functions, by someone signed in.

That third gate is the point of the whole design, and it is what lets everything else be permissive. An agent can write a function, test it, promote it, run it on real traffic and show you exactly what it would have done — and then you click.

10. What it costs

FreeGrowthPro
Live functions31025
Invocations a month50,000500,0005,000,000
Past the allowance$0.50 per 100,000, on your monthly statementsamesame
Hard cap5× the allowance5×5×

Every invocation is counted, whoever ran it: a cart render, a shipping quote, the payment page, the checkout, an order.event after an order is paid, and a test_store_function run from the dashboard or from an agent. There is no cheaper way to call a function, and no path that quietly does not count — which was not true before 2026-09-23, when only the checkout's own three hooks were metered and the other three, plus every test run, were free.

Counting happens in the function's own log rather than at any of those call sites, because the log is the one place every invocation arrives, whatever called the function. Invocations are folded onto your month in batches, so a number can arrive a few seconds after the run that produced it, and a burst of traffic moves one counter rather than thousands.

A function in shadow mode on a live store is counted: it is invoked on real traffic and costs exactly what a live one costs — that is the whole bargain of shadow mode, and the statement can say how much of the month was shadow. Test stores are never counted, never walled and never on a statement; their invocations are recorded on their own counter, which never reaches the allowance, the overage line or the cap.

If the platform cannot read your plan or your month, no function runs. The set comes back empty, every hook takes its declared fallback, and the store keeps selling exactly as it did before functions existed. Functions are optional; selling is not, and a reading that failed is not a reason to run merchant code unmetered.

A hook that runs on every cart render is the expensive one. A store with 50,000 sessions a month and a cart.price function will spend the Free allowance on page views alone. That is intentional and it is the honest shape of the cost.

At the hard cap, functions are skipped, not failed. Every hook takes its declared fallback, the store keeps selling, and you get a notice and a link to raise the limit. Nothing about running out of invocations may ever produce a broken checkout — and because every path is now counted, the cap trips on what the store actually spent rather than on the fraction of it any one caller happened to see.

Switch the feature on, or raise the allowance, at Plan & billing or through /dashboard#activate/store-functions. An agent never pays and never adds a card: it reads describe_account, which carries the payment profile first, and hands you the link.

11. Rules of thumb

  • **Read describe_store_functions before you write anything.** The fallback is a property of the hook, and it changes how you write the function.
  • Compute the answer, not the difference. The store does the subtraction and the clamping.
  • Never trust the clock. It does not move. Anything time-dependent should come from input.at, and a rule that needs a real calendar belongs in a scheduled discount, not a function.
  • Keep it pure. Same input, same output, every time. It is the only way to explain a price to a shopper, and it is what lets the store cache an answer within one checkout.
  • Fail small. A function that returns the empty answer when it is unsure is a function that never surprises anybody.
  • Shadow first, for anything that touches money. A week of real traffic costs three sentences of reading and buys you the whole argument.