Apps
Your own features on your own store: what an app is, every grant in plain words, its own records, blocks on your pages, and what it can never do.
An app is a small piece of software you — or your agent — write, which your store runs on your own address. Where a store function is one answer to one question inside the cart, an app is a feature: it has its own web addresses, its own records, its own files, its own settings screen, and it can show something on your storefront. Product reviews, return requests, a loyalty scheme, a wholesale enquiry form: the things every other platform sells you through an app store, written once for your store and owned by you.
Apps are on every plan, Free included. Plans differ only in how much an app may keep.
Two sentences to hold on to before anything else, because everything below follows from them:
- An app can only do what you ticked. Every capability is a line on one install screen, in plain words, and every one of them starts off — the screen ticks nothing for you. There is no "all", no permission that implies another, and no new version that can quietly widen what an earlier one was given.
- Only you install one. Your agent can write an app, test it on your test store and ask you to install it. Granting it anything is yours, always, and it is done by you or an admin, signed in — never by a token and never by a member of staff.
1. What an app is
One JavaScript file and a manifest.
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/hello")
return Response.json({ message: `Hello from ${env.APP}` });
return Response.json({ error: "Not found" }, { status: 404 });
},
};
That file is served at https://<your store>/apps/<name>/, and the path it sees is the part after the app's own name: the code above answers /apps/hello/hello.
The manifest says everything else:
{
"manifest": 1,
"slug": "reviews", // owns /apps/reviews/, its tables and its files
"name": "Reviews",
"version": "1.0.0",
"description": "Product reviews, asked for a week after delivery.",
"scopes": ["orders:read", "products:read", "storage:db"],
"tables": ["reviews"],
"migrations": ["CREATE TABLE IF NOT EXISTS app_reviews_reviews (…)"],
"outbound": [], // exact hostnames; none means it can call nothing
"secrets": [], // by name, each one its own question
"emailTemplates": [], // templates the app itself sends (§3)
"functions": [ /* §4a */ ], // store functions it brings, switched off
"schedules": [], // when it wakes on its own (§5a)
"blocks": [ /* §5 */ ],
"settings": [ /* §6 */ ]
}
An app is a single file. Formahand never installs a dependency, never runs a package manager and never builds anything for you: if your app needs a library, bundle it into the file before you upload it, exactly as a store function does. An app that tries to load a module while it is running is refused at upload, and the sandbox would not resolve it anyway.
2. What an app is handed
export default {
async fetch(request, env) {
const orders = await env.FORMAHAND.json("/api/orders?status=paid&limit=10");
const rows = await env.APP_DB.prepare("SELECT * FROM app_x_things").all();
return Response.json({ orders: orders.body, rows: rows.results });
},
};
On env | What it is | When |
|---|---|---|
FORMAHAND | Your store's own API, with the grants you ticked: /api/… and nothing else on Formahand. fetch(path, init) and json(path, init). | always |
SETTINGS | What you typed on the install screen. | always |
STORE, MODE, APP, VERSION | Your store's id, live or test, the app's name and version. | always |
INVOCATION | request when one of its addresses was called, schedule when a timer woke it (§5a). | always |
APP_DB | The app's own records. | Keep its own records granted |
APP_FILES | The app's own files. | Keep its own files granted |
SECRETS | Only the secrets you granted, by name, lower-cased. | per name granted |
**env.FORMAHAND takes a path, never an address.** "/api/orders?limit=10" works; anything with a host in it is refused, and so is a leading // or a ... That is not fussiness: it is what stops an app from aiming your store's own access at a server it controls. The same rule is kept again on our side of the boundary, where the app cannot reach it: a call from an app arrives at /api/… or it does not arrive. Redirects are not followed, and what comes back is a fresh answer with only the content type kept, so nothing rides along.
There is nothing else on env. Not your store's records, not your media, not anything of Formahand's. An app reaches your store the way your agent does — the same routes, the same rate limits, the same event log, with the app named as the actor — and there is no second door.
Apps see a visitor id, not your shopper's address or device. The request an app receives is not the shopper's request with a few things taken out of it; it is a new one, built from a short list. It carries the method, the path and the query, Content-Type, Accept and Content-Length, the app's own cookies, and one header:
x-formahand-shopper: v_9bK2qf1TxsA7Lm0dQzR4Yw
That is a visitor id. It is the same for one browser across that app's pages, different for every other app on your store, different again on your test store, and it is computed from a secret the app has never been given — so nobody holding it can turn it back into a person, and two apps cannot compare notes about the same shopper.
What an app does not get: your shopper's IP address, their browser or phone, their language, the page they came from, the country they are in, and never your storefront's own cookies — not the session, not the cart, not the discount. If one of those is ever something an app can ask for, it will be a line on the install screen with a sentence beside it, ticked by you.
x-formahand-app-base is the other header: /apps/<name>, so an app can write links back into its own addresses without guessing which of your domains a shopper arrived on.
3. The grants, in the words you will read
Every one of these is off until you tick it. An app can be granted anything an API token can hold: whatever your agent can do to your store, an app you install can be allowed to do, each as its own line here. The ones that move money or change what shoppers see live say "Tick this one deliberately", and they stay inside the same limits and approvals your agents are held to.
| Grant | What it lets the app do |
|---|---|
| Read your products | Titles, prices, images, stock and custom fields. |
| Change your products | Create and edit products, variants and custom fields. |
| Read your orders | Order numbers, lines, totals, status and tags. On its own it does not carry the buyer: an app with this grant and not the next one sees each order's customer as an id it cannot resolve, plus the country, and no email address, phone number or street. Still shown apart from everything else, because an order is about a person even when their name is not on it. |
| Read your customers — personal data | Names, email addresses, phone numbers, addresses and order history — and, with Read your orders, the buyer on every order in full. This is the one grant on the screen that hands an app real personal data, and it is never ticked for you. |
| Change your orders | Notes, tags and status. Never money or stock directly. |
| Refund an order | Ask your store to refund. Your store issues it, writes the ledger line and mails the shopper; the app only decides it should happen. |
| Issue gift cards | Let the app make gift cards for your shop. A gift card is money: whatever goes on one, your shop owes until somebody spends it. The app is held to the same money limits your agents are, every card says which app made it, and all of them are on your gift card list where you can stop any of them. Tick this one deliberately. |
| Read your store profile | Your store's name, legal name, postal address, contact address, timezone and weight unit. Only those. Not your plan, not your automations, not your subscriptions, not your email templates, not your integrations, and never a key or anything on your payment profile — each of those would be its own line here, and none of them exists yet. |
| Send one of your own emails | One of your templates, in your voice, from your verified address — to the buyer on an order it names, to you, or to somebody who gave the app their address through one of its own forms and confirmed it. The app names a template you own and the values it fills in; it can never write a sentence a shopper reads. To write to the buyer on an order it must also hold Read your orders, so this grant on its own can never reach a customer list. Anyone who has told your store to stop emailing them is refused, whatever the app asks. Counted on your own email allowance, and against a ceiling of 500 a day for that app — a ceiling kept against the app, so re-approving it does not hand it a fresh day. |
| Make one-off discount codes | Make single-use codes from a discount you marked as a template, for example one per newsletter sign-up. A code it makes is never worth more than the template, stops when you pause or end the template, and is on your discount list with the app named. At most 500 a day for the app and 2,000 a day for your store. |
| Read all your settings | Everything an agent with read access to settings sees: shipping, discounts, pricing and checkout rules, integrations, automations, event subscriptions, email templates, your plan and the names (never the values) of your secrets. Wider than Read your store profile. |
| Change your settings | Change shipping, discounts, rules, integrations, automations, event subscriptions and email templates, as an agent with write access to settings can. |
| Change your customers | Add customers, change their tags, notes and custom fields, and their customer group. On its own it does not let the app read who they are. |
| Download your customer list, personal data | Every customer in one file, with names, addresses and phone numbers. Never ticked for you. |
| Send email campaigns | Write and send campaigns to your newsletter subscribers, from your verified address, on your email allowance. |
| Read your store's event log, personal data | Every change recorded in your store and who made it. Order and customer events carry the buyer's email address and where the parcel went. Never ticked for you. |
| Read your storefront draft / Edit your storefront draft / Publish your storefront | The draft, changes to it (nothing reaches shoppers until publish; a function it saves arrives switched off), and putting the draft live. |
| Read your domains / Change your domains | Your store's addresses and their DNS records; adding, checking, removing and choosing the primary one. |
| Change your payment features and move your balance | Switch payment features and send your balance to your bank, held to the same limits and approvals as your agents. |
| Trigger one of your own event subscriptions | By name. An app never supplies an address. Declared today, not yet served: ticking it grants nothing. |
| Know which signed-in shopper is using it | When a shopper signed in to your store opens one of the app's pages, or a section calls it, the app is told an id for that shopper, the same every time. Personal data: never ticked for you (§4c). |
| Know the signed-in shopper's email address, personal data | Beside the id, the signed-in shopper's email address. Needs the line above too. Never ticked for you (§4c). |
| Keep its own records | Its own tables, inside your store's app allowance. |
| Keep its own files | Its own place for files, inside your store's app file allowance. |
| Run on a timer you set | Wake the app every so often with no shopper waiting — a weekly digest, a tidy-up, a check. At most every fifteen minutes, at most five timers, each run counted as one visit on your allowance. You can see every run, switch a timer off and run one by hand from the app's own card (§5a). |
Beside them, two separate lists: the secrets it wants, one name at a time, and the addresses it may call, one hostname at a time with no wildcards.
**An address is a hostname and a port.** Ticking api.example.com grants that name on the ordinary secure port and nothing else — not the admin interface somebody left on port 8443, not a search index on 9200, not the same name over plain HTTP. An app that genuinely needs another port writes it into its manifest (api.example.com:8443), and the screen shows it as api.example.com on port 8443 so you agree to the port as well as the name. An app with no addresses granted can reach nothing but your own store.
A new version that asks for more is a new install screen, with the new lines marked. A version that asks for nothing new goes live straight away, so a fix is a fix.
4b. Codes, loyalty and a store's own code together
The pieces of your store that run code work together through data, never by reaching into each other: an app keeps records and answers its own addresses, a store function decides inside the cart and after an order, and a storefront interaction presents the result. Existing owner-authored legacy section scripts can call app routes under their documented grants. The agent section behavior bridge has no general app route; do not use a legacy script as a workaround unless a trusted person explicitly owns and reviews that code.
- A personal code per person. An app granted Make one-off discount codes calls
POST /api/discounts/mint { template, email }and gets a single-use code bound to that address, never worth more than your template. Section scripts §9 has a whole sign-up popup built this way. - Loyalty points. An app with Read your orders, Keep its own records and the two grants that write a customer's custom fields (Change your products and Change your orders, as for any custom field on a customer) keeps a points ledger on a timer and writes each customer's balance to the custom field
functions.points; acart.pricefunction readscustomer.fields["functions.points"]for a signed-in shopper and takes an order discount; redeeming mints a code bound to the customer's email. An existing trusted legacy section script can show the balance through the app's own address (api.shopper.fromApp("loyalty", "balance")); the agent behavior bridge does not expose this app route. With Know which signed-in shopper is using it (§4c), the app knows whose balance to answer with. - A section talks to its app with
api.app("<name>").fetch("<path>"), on your store's own address.
Unless you tick one of the two shopper lines below, an app is never told who the shopper is: it gets the visitor id and nothing else.
4c. Knowing the signed-in shopper
Two grants, each its own line on the install screen, each ticked by you and never for you:
| Grant | What the app is told | Header on the app's request |
|---|---|---|
shopper:identify, Know which signed-in shopper is using it | An id for the shopper, when a shopper signed in to your store calls the app's address on your storefront | x-formahand-customer |
shopper:email, Know the signed-in shopper's email address, personal data | Their email address as well. Needs shopper:identify | x-formahand-customer-email |
When. Only on a request from your storefront to the app's own address, /apps/<name>/…: a page of the app, a block, a form, or a trusted legacy section script calling it (api.app(...), api.shopper.fromApp(...)). The isolated section behavior bridge has no general app route. Only while the shopper is signed in to your store and shopper sign-in is switched on. A shopper who is not signed in reaches the app exactly as before, with the visitor id and no customer header. A timer run has no shopper, so it has neither.
What the id is. s_ and 22 letters and digits: the same for that shopper on that app every time, on every device they sign in on, for as long as the app keeps its name; different for every other app on your store and on your test store. It is worked out from your store's own customer id with a key the app never holds, so nobody holding it can turn it back into the customer. An app that also holds Read your customers already reads your customers by their own id, so it is given that id instead (cus_…), and can match the shopper to their orders.
Never the sign-in itself. Your store checks the shopper's session and tells the app the answer. The session cookie, and every other cookie of your store's, is still removed before the app sees the request, and a shopper cannot send either header to an app themselves: the request an app receives is built by the store, and these headers are only ever written from the store's own answer. An answer the app writes for a signed-in shopper is never kept by a cache, whatever the app asks for.
What this means for your shoppers' privacy. Read this before you tick either line.
- With the id, the app can recognise the same signed-in shopper every time, on every device, and keep what it likes against that id in its own records: points, visits, what they looked at in the app. That is a profile of a person, even without a name on it. Tell your shoppers in your privacy policy that an app on your store does this and why.
- With the email address, the app holds personal data about a real person. You are responsible for it as for any other personal data your store collects: say in your privacy policy what the app uses it for, and tick it only for an app that has to write to shoppers or match them in another system. If the app may call out to other addresses (the addresses list on the install screen), the email can leave your store that way.
- Nothing the app learns this way can reach another app, and the ids of two apps cannot be compared.
- To stop it, uninstall the app, or install it again with the line unticked. Within seconds the app is told nothing more. What it already stored is in its own records: uninstall with "remove its data" to delete them (§1), or ask the app's author to delete one shopper's entry when that shopper asks you to.
A loyalty balance, end to end. The app, granted Know which signed-in shopper is using it and Keep its own records:
export default {
async fetch(request, env) {
const customer = request.headers.get("x-formahand-customer");
if (!customer)
return Response.json({ error: "Sign in to see your points." }, { status: 401 });
const row = await env.APP_DB
.prepare("SELECT points FROM app_loyalty_points WHERE customer = ?")
.bind(customer).first();
return Response.json({ points: row?.points ?? 0 });
},
};
and a section's script on any page scripts run on:
export default async function mount(root, api) {
const { signedIn } = await api.shopper.get();
if (!signedIn) return;
const { points } = await api.shopper.fromApp("loyalty", "balance");
root.querySelector("[data-points]").textContent = points;
}
4a. Store functions an app brings with it
An app is a feature with its own addresses; a store function is one answer to one question inside a cart, a checkout or an order. Some features need both — the Reviews add-on is the example, because "ask for a review a week after the parcel lands" is a question about an order and nothing to do with a web address.
So an app may carry store functions in its manifest, source and all, and installing it registers them the same way your agent would.
"functions": [
{ "name": "review-request", "hook": "order.event", "source": "export default { event(input) { … } }" }
]
Three things about that, and the first is the one that matters:
They arrive switched off. Installing an app never puts code on your cart, your checkout or your orders. Several functions may sit on one hook: an app's run after your own by default (position 100), and you can reorder them. The function appears on Online Store → Apps & functions beside the ones you wrote yourself, off, and you switch it on there — and on your live store only you or an admin can, never a token. That is the same line every function is held to, and it is why bringing one costs no line on the install screen: an app that brings a function has not been granted anything, it has offered you something to turn on.
They run as your store, not as the app. A function is handed its input by Formahand and answers with actions — a tag, a note, one of your own email templates, one of your own event subscriptions. It never holds the app's token, never sees the app's records, and can name nothing you do not already own. An app with no grants at all can still bring one, and a grant you untick changes nothing about it.
They go when the app goes. Uninstalling an app deletes the functions it registered, so switching an app off is not a thing that leaves code behind on your orders.
5a. Scheduled runs
Most of what an app does happens because somebody asked for it: a shopper opened a page, a block fetched some JSON, a form was posted. A timer is the other kind of work — the weekly digest, the nightly tidy-up, the hourly check on somebody else's system — and it is the thing an app cannot arrange for itself.
An app declares its timers in its manifest, you grant them on the install screen, and Formahand wakes the app at those times.
"scopes": ["orders:read", "storage:db", "schedule:cron"],
"schedules": [
{ "name": "weekly-digest", "every": "1d", "handler": "digest" },
{ "name": "poll-supplier", "every": "15m" }
]
every is one of **15m**, **1h**, **6h** and **1d**, and nothing else. There is no expression language, on purpose: a line of stars and slashes is a small vocabulary that nobody reads the same way twice, and everything it can say that these four cannot is really a question about what time of day, which an app answers for itself by looking at the clock when it wakes.
What the app writes. A timer calls an export of the app's own, beside its fetch:
export default {
async fetch(request, env) { /* its pages */ },
async scheduled(invocation, env, ctx) {
// invocation = { kind: "schedule", name: "poll-supplier",
// at: "2026-09-24T09:15:00.000Z", runId: "…:2026-09-24T09:15:00.000Z" }
const orders = await env.FORMAHAND.json("/api/orders?status=paid&limit=50");
// …
},
};
handler in the manifest names another export instead of scheduled, which is how one app keeps a fifteen-minute poll and a daily digest apart. Whatever the handler returns is ignored unless it is a Response; what is recorded is whether it threw.
A run can happen twice, and the app is told which run it is. Before Formahand runs a timer it takes a lease on it, and it gives that lease two minutes. That is deliberate: a platform machine that dies halfway through a run would otherwise hold the lease for ever and your timer would simply stop, silently, until somebody noticed. The price is that a run which outlasts its lease can be started a second time — a timer runs at least once per slot, not exactly once.
So every run carries an id. invocation.runId names the slot being served, not the moment the run started, which means the first attempt and the second attempt at the same slot carry the same id, and the next slot carries a different one. An app doing anything that must not happen twice — sending a message, taking a payment, posting to somebody else's system — writes the id down before it does the work and does nothing at all when it sees that id again:
async scheduled(invocation, env) {
const done = await env.APP_DB.prepare(
"INSERT OR IGNORE INTO digest_runs (run_id) VALUES (?)",
).bind(invocation.runId).run();
if (!done.meta.changes) return; // this slot has already been done
// … the work that must not happen twice
},
invocation.at is not that key. It is the clock of the moment your app was woken, so two attempts at one slot have different ats. Run it now is not a repeat either: it gets an id of its own, so testing a fix by hand never makes the real run look like something already done.
A timer gets the same everything a page does. The same grants, the same records and files, the same secrets, the same addresses it may call, the same 256 KB and the same thirty seconds. Two differences: there is no shopper, so there is no request, no cookie and no visitor id; and an app that declares a timer may ask for a little more processing time in its manifest ("limits": { "cpuMs": 500, "subRequests": 50 }), because work with nobody waiting is allowed to think for longer. That raise applies to the app's own pages too — one number per app — which is why it is on the install screen and why only an app that actually runs on a timer may ask for it.
What you see, and what you can do. Every installed app's card lists its timers: how often each runs, when it last ran and how that went, when it runs next, and two buttons. Switch it off stops one timer and leaves the app's pages working. Run it now runs one immediately without moving its next slot — how you check that a fix worked — and is allowed ten times an hour for the whole store, because a run costs a visit like any other.
When one keeps failing. A timer that fails waits longer before trying again: fifteen minutes, then an hour, then six hours. After ten failures in a row it switches itself off, the card says so, and we email you. Nothing else about your store is affected — the app still answers its own pages, and your orders, checkout and email are untouched. Fix the app and switch the timer back on; switching it on clears the run of failures, so it starts again straight away rather than waiting out its backoff.
What it costs. Every scheduled run is one invocation on the same allowance a visit to the app's address uses — 50,000 a month on Free, 500,000 on Growth, 5,000,000 on Pro. There is no separate charge and no separate line on your statement. There is a separate daily ceiling, because a timer is the one part of an app nobody is watching:
| Free | Growth | Pro | |
|---|---|---|---|
| Scheduled runs a day, all apps | 100 | 1,000 | 10,000 |
Past it the timers are marked waiting for tomorrow and nothing is run until the day turns over. Your store, your checkout and your pages are untouched by that, exactly as they are untouched by the records allowance.
Your test store runs its timers too, and they are never billed and never walled — like everything else in a sandbox. And because an app on your test store and an app on your live store are separate things (§9), promoting to live does not bring a timer with it: apps are installed per environment, and so are the timers they came with.
A worked example: a weekly digest
An app that emails you nothing and posts nothing outward, and still saves you opening the dashboard on a Monday: once a day it reads the week's paid orders, works out the five products that sold most, and keeps the answer in its own records so a block on your storefront — or you, from list_app_records — can read it instantly.
{
"manifest": 1,
"slug": "digest",
"name": "Weekly digest",
"version": "1.0.0",
"description": "Works out the week's best sellers once a day and keeps the answer.",
"scopes": ["orders:read", "storage:db", "schedule:cron"],
"tables": ["weekly"],
"migrations": [
"CREATE TABLE IF NOT EXISTS app_digest_weekly (day TEXT PRIMARY KEY, body TEXT NOT NULL)"
],
"schedules": [{ "name": "weekly", "every": "1d" }],
"limits": { "cpuMs": 500 }
}
export default {
async fetch(request, env) {
const row = await env.APP_DB.prepare(
"SELECT body FROM app_digest_weekly ORDER BY day DESC LIMIT 1",
).first();
return Response.json(row ? JSON.parse(row.body) : { products: [] });
},
async scheduled(invocation, env) {
const since = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
const answer = await env.FORMAHAND.json(
`/api/orders?status=paid&since=${since}&limit=200`,
);
const sold = new Map();
for (const order of answer.body?.orders ?? [])
for (const line of order.lines ?? [])
sold.set(line.title, (sold.get(line.title) ?? 0) + (line.quantity ?? 0));
const products = [...sold.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([title, quantity]) => ({ title, quantity }));
await env.APP_DB.prepare(
"INSERT INTO app_digest_weekly (day, body) VALUES (?, ?) ON CONFLICT(day) DO UPDATE SET body = excluded.body",
)
.bind(invocation.at.slice(0, 10), JSON.stringify({ since, products }))
.run();
},
};
Three things worth copying from it. It asks for **orders:read and nothing more** — it never needs to know who bought, so it never sees an email address or a street. It does the slow thing on the timer and the fast thing on the request, so the page it serves is a single row read. And it writes one row a day rather than one per order, which is the difference between a rounding error on your records allowance and a problem.
5. Showing something on your storefront
An app never puts JavaScript on a shopper's page. That is the same rule custom code follows and apps do not get an exception to it. What an app provides instead is a block, and the store draws it.
There are two kinds.
A list block gives a template — ordinary markup with {{field}} placeholders — and an address of the app's own to read JSON from. Your store fetches the JSON, fills the template in with every value escaped as text, and shows the result. The markup is rebuilt from the same allowed tags a custom block of yours goes through, so it has no script, no inline handler, no form and no frame.
{
"id": "list", "title": "Reviews", "kind": "list",
"slots": ["product.below"], "path": "list", "itemsKey": "reviews",
"template": "<blockquote><p><strong>{{stars}}</strong> {{author}}</p><p>{{body}}</p></blockquote>",
"empty": "<p>No reviews yet.</p>"
}
A form block gives a list of fields, and your store draws the form itself and posts it to the app as JSON. An app never ships a form, because a form is the one piece of markup that can send a shopper's typing somewhere. A form block can also wait on a link: requireParam means it stays hidden until the address carries that query parameter, which is how a link in an email opens a form only the person who was sent it can use.
{
"id": "write", "title": "Write a review", "kind": "form",
"slots": ["home.below"], "path": "submit", "requireParam": "review",
"fields": [
{ "name": "rating", "label": "How many stars?", "type": "rating", "required": true },
{ "name": "body", "label": "What did you think?", "type": "textarea", "required": true }
]
}
A block on a product page is told which product it is on. A block whose app is uninstalled shows nothing, rather than a stale copy of what the app used to say.
6. Settings
The manifest declares the fields; the install screen renders them; the merchant fills them in; the app reads env.SETTINGS.
"settings": [
{ "type": "boolean", "key": "autoPublish", "label": "Publish reviews straight away", "default": false },
{ "type": "number", "key": "minRatingToPublish", "label": "…at this many stars or more", "min": 1, "max": 5, "default": 4 }
]
Types are text, number, boolean and select. A change takes effect on the app's next request. app_settings reads and writes the same values.
7. What an app can never do
This is the part to read, because it is what makes an app safe to install.
| It cannot | Because |
|---|---|
| See a shopper's session, cart or discount | Every cookie is removed from the request before the app sees it. The app gets its own, under its own name, and nothing else. Even with the shopper grants (§4c) it is told who is signed in, never handed the sign-in. |
| Know who a shopper is without your say | Only with Know which signed-in shopper is using it, ticked by you, and only a per-app id; their email only with the second line (§4c). |
| Know your shopper's address, device, language or country | The request an app receives is built rather than forwarded: it carries a visitor id and no header that describes the person behind it (§2). |
| Read a buyer's email address, phone number or street | Not unless you ticked Read your customers. Without it, every order an app reads comes back with the buyer as an id it cannot resolve and a country. |
| Read your plan, automations, subscriptions, email templates, integrations or secrets | Read your store profile is your store's profile and nothing else. There is no grant on this screen that opens the rest, and there is no route an app can reach that does. |
| Set a cookie of the store's or of Formahand's | Anything it sets that is not in its own jar is dropped on the way out. |
| Put a script on a shopper's page | An answer that is markup is rebuilt from your store's own allowed tags. Anything that is not JSON, markup or plain text is not served at all. |
Answer /checkout, /cart, /api or / | It is only ever handed paths under its own name. The others never reach it. |
| Take a name your storefront or your email records already use | Refused at upload. |
| Call an address you did not grant | Every call out goes through a gate that knows the list — the exact hostname and the exact port. A redirect to somewhere else is not followed. |
| Open a socket | There is none to open. |
| Reach Formahand, another store, or an address on a private network | Refused by the same policy your own webhooks follow. |
| Read your store's records directly | There is no such path. Everything goes through your store's API with the grants you ticked, recorded against the app. |
| Write the words in an email | Send one of your own emails lets it name a template you own and the values that fill it in, and nothing else. The wording is yours, edited under Automations, and an app cannot put a sentence in front of a shopper that you did not write. It has no way to send anything else: there is no mail of its own on env, and the route it uses takes a template name or refuses. |
| Mail a list | One message, one recipient, one call. Counted on your own email allowance and stopped at 500 a day for that app — so a loop is a line in the app's log, not a reputation nobody gets back. |
| Send a webhook anywhere | Only to a subscription you already made, by name. |
| Be installed by a token | Only a signed-in owner or admin grants an app anything. |
An app that has been uninstalled stops answering immediately and its access to your store is revoked in the same moment — not disabled, revoked.
8. The loop: build one with an agent
| Tool | Does |
|---|---|
describe_apps {} | Every grant in plain words, what an app is handed, what it can never do, the limits. Read this first. |
list_addons {} | The catalogue (§13): what each add-on does, what it asks for, and what your store has already done with it. Read this before writing an app that already exists — and read its source before writing one that nearly does. |
create_app { manifest, source } | Stores it as a draft. Nothing is granted and nothing is served. |
update_app { manifest, source } | A new version. Asks for nothing new → live now. Asks for more → back to waiting for you. |
list_apps {} | Every app, its state, what it was granted and how full the storage is. |
get_app { name } | One app in full, with the history of every grant and a day-by-day rollup of what it touched. |
request_install { name } or { catalog } | Asks you to install one of your own apps, or one of ours from the catalogue. As far as a token goes, for either. |
uninstall_app { name, removeData? } | Switches it off now. Always allowed. |
app_settings { name, settings? } | Reads or changes what you configured. |
list_app_records { name, table, where?, limit? } | Reads rows from one of the app's own tables. |
set_app_record { name, table, id, set?, remove? } | Changes or removes one row — how a review is approved or hidden. |
list_app_schedules { name? } | Every timer the store's apps run on: how often, last run and how it went, next run, on or off, and the runs spent today. |
run_app_schedule_now { name, schedule } | Runs one timer immediately without moving its next slot. Ten an hour per store. |
disable_app_schedule { name, schedule } / enable_app_schedule { name, schedule } | Stops one timer, or starts it again from its next slot. |
Nothing on that list installs an add-on or takes one over, and nothing ever will: both are decisions about your store's data and your store's code, and they are made by a person on a screen (§13).
Nothing on that list creates a timer, and nothing ever will: a timer exists because a manifest declared it and you ticked Run on a timer you set. A tool that could add one would be a tool that widened a grant after you gave it.
The loop is: describe_apps → write → create_app on your test store → open its addresses → request_install → you approve → place its blocks → promote_to_live → you approve it there too.
Everything these tools do, /api/store-apps does, and the reverse — with one exception, which is the point: installing is on the route and on no tool, because it is done by a person. An app's own token is the other side of the same line: it reaches /api/… with the grants that were ticked, and nowhere else on Formahand.
9. Test store, live store
An app exists per environment. The one on your test store and the one on your live store are separate things, with separate records, separate grants and separate settings. App data never travels between them, in either direction: an app's records are like your orders, not like your products.
On a test store: as many apps as you like, nothing counted, nothing walled, no shopper ever sees them.
10. What it costs
| Free | Growth | Pro | |
|---|---|---|---|
| App records | 100 MB | 1 GB | 5 GB |
| App files | 1 GB | 20 GB | 100 GB |
| Rows written a day, all apps | 100,000 | 1,000,000 | 5,000,000 |
| Past the records allowance | $0.05 per GB a month, on your statement | fair use | fair use |
Every request to an app's address counts as one invocation on the same allowance store functions use: 50,000 a month on Free, 500,000 on Growth, 5,000,000 on Pro, then $0.50 per 100,000 on your monthly statement. A scheduled run counts as one of those too — the same allowance, the same statement line, no separate charge — and has its own daily ceiling besides (§5a). App files are fair use on every plan and are never billed.
The records reading is taken once a day, so a number can be up to a day old — the same staleness the media reading already has. Test stores are never counted, never walled and never on a statement.
Raise the records allowance at Plan & billing or through /dashboard#activate/apps-storage. An agent never pays and never adds a card: it reads describe_account and hands you the link.
11. Limits
| Budget | Value |
|---|---|
| The file | 1 MB |
| Time to answer | 30 seconds |
| Processing time | 200 ms |
| Calls out | 20 per request |
| Answer | 256 KB |
| Requests a minute, all of a store's apps | 600 |
| Blocks a store may place | 8 |
| Timers an app may declare | 5 |
| Shortest a timer may be | 15 minutes |
| Processing time, an app that runs on a timer | 500 ms, if its manifest asks |
| Calls out, an app that runs on a timer | 50 per run, if its manifest asks |
Going over any of them is an error the app's caller sees, never something the store hides. An answer over 256 KB is refused with a message that says so rather than quietly cut short — an app that returns half a page and no explanation is the hardest kind of bug to find. If an app has more than that to say, it pages.
(A custom block you write yourself stays at 32 KB of markup. That is a field you type into; an app's answer is generated, and a list of fifty reviews is legitimately larger.)
12. Rules of thumb
- Ask for as little as you can. The install screen is a list somebody reads, with nothing ticked in advance. An app that wants to read your customers had better be able to say why in one sentence — and most apps that think they need it need Read your orders, which tells them an order exists, what was on it and where it got to, without telling them who bought it.
- Answer JSON. Markup is rebuilt on the way out, so an app that answers JSON and lets a block draw it keeps control of what it looks like.
- Do the slow thing off the request. A shopper is waiting for 30 seconds at most; a call to somebody else's server belongs behind a page that is already loaded, not in front of one.
- Check the order, not the link. A link proves who was sent it. Whether they bought the thing is a question for your store's API.
- Handle the allowance. Writing can fail with a sentence about the allowance. Show it; do not swallow it.
- Keep the records small and the files large. A photo belongs in the file allowance, and a row that holds one is a row that costs fifty times what it should.
13. Add-ons
Online Store → Apps & functions → Add-ons is a catalogue of apps we wrote.
The important thing about them is what they are not: they are not a privileged kind of app. Each one is an ordinary app, built only on what this page describes, installed through the same screen with the same list of grants and nothing ticked, running in the same sandbox with the same limits. If one of them ever needed something your own app could not have, the thing that was wrong would be this page, not the app.
They are also the best documentation there is. Every one of them ships as source in apps/<name>/ — the manifest, the single file, and a README written the way this page is — so "how would I do that?" has an answer you can read and copy.
| Add-on | What it does |
|---|---|
| Reviews | Asks for a review a week after the parcel lands, and shows the stars on the product page. |
| Gift cards | Sells gift cards. The buyer pays, the person it is for gets the code, and they spend it at your checkout like money (payments.md § Gift cards). |
| Wishlist | A save-for-later list, kept against an anonymous visitor id. No accounts, no addresses, nobody's name. |
| Back in stock | Shoppers ask to hear when something sold out is back, confirm the address is theirs from a link, and hear once. The address is forgotten as the email goes. |
| Contact form | A contact form that emails you the message and stores nothing but a counter. |
| Questions and answers | A list you write yourself, shown wherever you put the block. The smallest complete app there is. |
| Size guide | A measurements table under the products it applies to, chosen by your own product tags. |
Installing one
The same screen, the same rules. Nothing on it is ticked for you, and "we wrote it" is not consent: an add-on that wants to read your orders has to say why in one sentence, exactly as anybody else's would.
Your agent can ask for one — list_addons to see what there is, request_install { catalog: "reviews" } to ask — and that is as far as a token goes, as always. There is no install_addon tool.
Keeping one up to date
An add-on you installed is pinned to the version you installed, so "there is a newer Reviews" is a question with an answer. When there is one, the card says Update available, and applying it goes through the ordinary rule: a version that asks for nothing new is live at once, and one that wants another grant is an install screen again, with the new lines marked. An update can never widen what you agreed to. That is the only reason an update button is safe to put on a screen.
Making one yours
Make it mine takes an add-on over. After it:
- the code is your store's. Your agent changes it with
update_applike anything else it wrote, and the app's author becomes you; - it keeps its name, so its addresses, the blocks you placed and everything it has already saved carry on working. Forking is not a migration;
- our updates stop reaching it — including anything we fix. That is the cost, it is on the confirmation, and it is the reason this is a person's decision and not a tool.
There is no un-fork. Getting our version back means uninstalling yours first, which is a decision with a screen rather than a silent overwrite of code somebody edited.