Storeware Docs

Checkout functions

Small pure pieces of your hosted app that Storeware runs when a basket is priced, for web checkout and AI agents alike.

A checkout function is a small piece of your app's code that Storeware runs at the moment a basket is priced. It can take an amount off, hide or rename delivery options, choose which payment methods are offered, or refuse the basket with a reason the shopper can act on.

Functions are different from the calls Storeware makes to your app's address, such as delivery rates:

  • They run inside Storeware. Your code is loaded next to checkout, so there is no trip to a server and nothing to keep running.
  • They run everywhere a basket is priced. The shop's own checkout and an AI agent buying for someone are priced the same way, so your discount or rule holds for both.
  • They run on every plan. A merchant on any plan can install an app with functions.
  • They are pure and sealed. A function is handed JSON and answers JSON. It has no network, no env, and a few milliseconds of CPU.

Functions need hosting: they ship in the same bundle as the rest of your hosted app.

Declare them

Each function has a [[functions]] entry in storeware.app.toml:

storeware.app.toml
[oauth]
scopes = ["checkout:read"]

[runtime]
main = "src/worker.ts"

[[metafields.definitions]]
owner_type = "product"
key = "pack_size"
name = "Pack size"
type = "integer"

[[functions]]
kind = "cart_validation"
handle = "packs_only"
name = "Sold in packs"
description = "Refuses a basket when a product sold in packs has a quantity that is not a whole pack."
metafields = ["product.pack_size"]
KeyMeaning
kinddiscount, delivery_rates, payment_methods or cart_validation.
handleThe name it is exported under. Lower case letters, digits and _, starting with a letter, 2 to 64 characters.
nameWhat a merchant sees, such as Volume discount. Up to 60 characters.
descriptionOne sentence for the merchant on what it changes. Up to 200 characters.
metafieldsFields your app keeps that the function is handed, as product.<key>, variant.<key> or customer.<key>. Up to 10.

An app may have up to 10 functions, each with its own handle. Functions need [runtime] and the checkout:read scope, because every function is handed the basket and who is buying.

Write them

Export functions from the same file as your app's fetch handler. defineFunctions from @storeware/plugin-sdk/functions checks each one against its kind as you type:

src/worker.ts
import { defineFunctions } from '@storeware/plugin-sdk/functions';

export const functions = defineFunctions({
  packs_only: {
    kind: 'cart_validation',
    run: (input) => ({
      errors: input.lines
        .filter((line) => {
          const pack = Number(line.metafields['product.pack_size'] ?? 1);
          return pack > 1 && line.quantity % pack !== 0;
        })
        .map((line) => ({
          message: `Sold in packs of ${line.metafields['product.pack_size']}`,
          lineId: line.id,
        })),
    }),
  },
});

export default {
  async fetch(request: Request): Promise<Response> {
    // Your app as before.
    return new Response('Not found', { status: 404 });
  },
};

Storeware looks up only functions[handle], an own property that is a function, and calls it with the input. It may return its answer or a promise of it.

What every function is handed

Every kind is handed the basket, who is buying and where it is going. Amounts are in the basket currency's minor unit, such as cents or pence. Lines come in order of their id, so the same basket always gives the same input.

{
  "storeId": "st_8f2…",
  "currency": "GBP",
  "lines": [
    {
      "id": "ln_1",
      "productId": "pr_7…",
      "variantId": "va_3…",
      "sku": "TEA-12",
      "title": "Breakfast tea",
      "variantTitle": "Box of 12",
      "quantity": 5,
      "unitPrice": 450,
      "grams": 300,
      "requiresShipping": true,
      "metafields": { "product.pack_size": 6 }
    }
  ],
  "subtotal": 2250,
  "buyer": {
    "customerId": "cu_4…",
    "companyId": null,
    "email": "sam@example.com",
    "metafields": {}
  },
  "destination": { "country": "GB", "region": null, "postalCode": "LS1 1AA", "city": "Leeds" },
  "discountCode": null,
  "delivery": { "id": "standard", "type": "shipping", "title": "Standard", "amount": 395 },
  "channel": "web"
}

channel is web for the shop's own checkout and agent for an AI agent's. buyer.companyId is set when a business is buying on its own prices and terms. delivery is the option chosen so far, after any delivery function ran. Each line's metafields and the buyer's hold only the fields the function declared.

The four kinds

discount

Amounts off lines, the order or delivery. Handed the basket as above. Answers up to 5 discounts:

{
  "discounts": [
    { "title": "Bulk order, 10% off", "target": "order", "value": { "percent": 10 } },
    { "title": "Free delivery over £50", "target": "delivery", "value": { "amount": 395 } },
    { "title": "Tea, £1 off a box", "target": { "lines": ["ln_1"] }, "value": { "amount": 100 } }
  ]
}

title is what the shopper and the agent are shown, after your app's name, up to 60 characters. target is order, delivery or { "lines": [...] } by line id. value is { "percent": 0 to 100 } or { "amount": n } in minor units.

delivery_rates

Hide, rename, reorder or reprice what the basket already offers. Handed the basket and its options:

{
  "options": [
    { "id": "standard", "type": "shipping", "title": "Standard", "amount": 395 },
    { "id": "next_day", "type": "shipping", "title": "Next day", "carrier": "DPD", "amount": 795 },
    { "id": "economy", "type": "shipping", "title": "Economy", "amount": 250 }
  ]
}

Answers the options to offer, in order. An option left out is hidden. Only ids the input named may appear, each once:

{
  "options": [
    { "id": "next_day", "title": "Next day, before 1pm" },
    { "id": "standard", "amount": 0 }
  ]
}

When several delivery functions apply, each sees the menu the one before it left, and together they have one second. Any not reached in time are skipped and the menu stays as the others left it.

To offer new rates of your own, use delivery rates instead.

payment_methods

Hide or reorder how the shopper may pay. Handed the basket and its methods, from card (every card and wallet), on_account (paying later on a business's terms) and crypto (stablecoin payment):

{ "methods": ["card", "on_account", "crypto"] }

Answers the methods to offer, in order. A method left out is not offered, and is refused if tried:

{ "methods": ["on_account", "card"] }

cart_validation

Refuse a basket, with a reason. Handed the basket as above. Answers up to 5 errors, each with a message the shopper and the agent are shown after your app's name and, if it is about one line, that line's id:

{ "errors": [{ "message": "Sold in packs of 6", "lineId": "ln_1" }] }

An empty list lets the basket through.

Failing safe

If a function throws, runs too long, or answers something its kind does not allow, checkout carries on as if it were not there. A discount function gives no discount, a delivery_rates function leaves the options as the shop built them, a payment_methods function leaves every method offered, and a cart_validation function refuses nothing.

A shopper never sees your function fail. You do, in your logs and with the commands below.

Limits

Each call may use 25 ms of CPU time and 250 ms in all, waiting included, and may answer up to 16 KB of JSON. A function has no network, so fetch throws, and no env.

Functions run in an isolate of their own, apart from your app's fetch handler, so nothing your handler holds is reachable from a function, and the other way around.

A function must be pure. Its answer is kept for the exact input it was given, and used again whenever the same basket is priced, including when the order is placed. The same input must always give the same answer, so do not read the clock, pick at random, or count calls. Anything a function needs to decide, such as a pack size or a customer's tier, belongs in a field it declares.

Fields

A function is handed only the fields named in its metafields, and only fields your own manifest declares under [[metafields.definitions]]. It never sees another app's fields, or the merchant's own. A product field is on every line as product.<key>, a variant field as variant.<key>, and a customer field on buyer.metafields as customer.<key>.

Merchant approval

Adding a function is a new release, and each merchant approves it before it runs at their checkout. They are shown its name and description. Until they approve, their shop keeps the release it has, with the functions it had.

Try them

Print a sample input for a kind, to start a test file from:

npx @storeware/cli function sample discount

Run a function on this machine, with no network, and check its answer against its kind:

npx @storeware/cli function run packs_only --input basket.json

With no --input, it is handed the sample for its kind.

Run a function on the baskets one of your development stores priced recently, to see what it would have answered:

npx @storeware/cli function replay packs_only --store my-dev-shop --limit 50

See function run, function replay and function sample for every flag. What a function logs with console.log shows in app logs --console, with your app's other logs.

On this page