Agents and API
Scoped API tokens, the MCP endpoint and its tools, the storefront read API, headless checkout, order export and the draft-to-publish loop.
How a merchant's own scripts and AI agents act on a Formahand store without a browser session: scoped API tokens, the MCP endpoint and its tools, the public storefront read API, and the event log that tells an agent what changed. Written 2026-09-13; events and automations 2026-09-14; environments, describe_system and get_started 2026-09-15; customers, analytics, checkout rules, content, product kinds, shopper accounts, recipes and governance 2026-09-16.
The parity rule. Everything the dashboard can do to a store, a token can do; there is no separate "agent" data path, no second-class API and no feature that only a human can reach — except the handful of decisions listed in Never possible with a token, each of which is a signature, a credential or the end of the business. Every /api/* route either carries a scope a token can hold or stands in that list with its reason, and every scoped route has an MCP tool or a written reason why it has none — because a route a token can call but no tool exposes is still out of reach of an MCP client. Both halves are checked on every build, so the promise cannot rot quietly in either direction.
Where to start. An agent that has read nothing calls describe_system (this page's guide) and then get_started (this store's state and the ordered next calls). Everything below is the reference behind those two.
API reference. GET https://formahand.com/api/openapi.json is the whole REST surface as OpenAPI 3.1: every /api/* route a token can call, the methods it answers, the scope each needs (x-required-scope), the MCP tools that do the same job (x-mcp-tools) and every tool's input schema under components.schemas as tool.<name>. GET https://formahand.com/api is the short index: { docs, openapi, mcp, llms, auth, tools: [{ name, description, scope }] }. Both are public, cacheable and hold no store data.
The Formahand skill
One install gives a coding agent the whole working guide to a Formahand store: the first calls of every session, the draft to publish loop, what custom HTML and CSS keep, apps, store functions, events and webhooks, environments, and the money decisions that stay with the merchant. It is generated from this page, the other docs and the tool list, so it never says anything they do not. Every client gets the same skill; only the install differs.
- Any agent (the skills CLI):
npx skills add formahand/skills --skill formahand. Add-gto install it for every project. The CLI asks which agents to install for. - Claude Code (plugin marketplace):
/plugin marketplace add formahand/skills, then/plugin install formahand. - Codex: Codex reads skills from
.agents/skillsin the repository (and in each folder between your working directory and the repository root) and from~/.agents/skillsfor every repository. Run the skills CLI above and choose Codex (it writes.agents/skills/formahand), copy theformahandfolder of https://github.com/formahand/skills into~/.agents/skills/, or ask Codex's own installer:$skill-installer install the formahand skill from https://github.com/formahand/skills. Restart Codex if it does not appear. - Without installing: read https://formahand.com/skills/formahand/SKILL.md; its references are at
https://formahand.com/skills/formahand/references/<name>.md, and https://formahand.com/skills lists every file as JSON. All three are public and hold no store data.
To make sure Codex (or any agent that reads AGENTS.md) opens the skill before it touches the store, add this to the project's AGENTS.md:
## Formahand
Before any work on the Formahand store, read the `formahand` skill
(`.agents/skills/formahand/SKILL.md`, or https://formahand.com/skills/formahand/SKILL.md
if it is not installed) and follow it: make its first five calls, and never add a
payment method, choose a paid package or pay for anything.
How an agent should think about Formahand
Formahand is a hosted storefront platform: each store is fully isolated, with its own data and its own media library, reached at <slug>.store-on-formahand.app or the merchant's own domain, sold through Formahand Payments (card payments, provided with Stripe), PayPal or manual payments, shipped by flat rates, a courier platform or Formahand Shipping labels, with an event log that feeds webhook subscriptions and in-store flows. Agents act through scoped API tokens on the same /api routes the dashboard uses, or through the MCP endpoint at /mcp. A token's prefix binds it to one environment: a live token (prefix fh_<mode>_) acts on the real store, a test token (prefix fh_test_) on the store's sandbox (a fully separate store at <slug>-test.store-on-formahand.app with payments in test mode, free test labels and owner-only email). Storefront changes go to a draft and reach shoppers only through publish_storefront; catalog, settings and orders change live immediately. Plan limits answer as structured walls (402 plan / 409 setup) with steps an agent can follow.
What a store is
One merchant store = one isolated store with its own data and media library, one or more hostnames, one payment account, one plan. get_store names it (id, hostnames, runtime status, theme, live version, mode). Everything a token can do the dashboard can do; there is no separate agent data path.
Every write is validated by the store and recorded in the event log (list_events).
Live vs test (the sandbox)
A store has a live environment and, once enabled, a sandbox: a fully separate store at <slug>-test.<domain> with payments in test mode, test labels and email routed only to the owner with a “[Test]” subject. Orders there are not real: no usage, no bill, no plan wall.
A token acts on exactly one environment, fixed by its prefix: a live token (fh_<mode>_…) reaches the live store, a test token (fh_test_…) the sandbox. get_store and get_environment return mode; there is no per-call switch.
get_environment shows both; enable_sandbox creates the sandbox (poll until sandbox.runtimeStatus is ready); refresh_sandbox overwrites it from live; promote_to_live { dryRun: true } previews, then promote_to_live copies sandbox content to live (see Promote). Live-only: custom domains, plan and billing, statements, the payment profile.
Draft → publish
Storefront appearance and copy (theme, sections, menus, pages) live in a revision-checked draft: get_storefront_draft → apply_storefront_commands (listed by list_modules) / set_hero_image / set_navigation / set_content_pages with that revision → publish_storefront { revision }. Shoppers see nothing before publish; a stale revision conflicts: re-read, retry. Pick a look: list_themes, then set-theme { presetId }.
Sections (describe_sections → save_section): put data-fh-el="<setting key>" on every element printing a text, image or link setting and data-fh-group on reorderable flex or grid containers, or saving is refused (element_unmarked); merchants then edit each element in the builder. One element: update-section for text, set-element-style { instanceId, key, align, size, visible, spacing, order, offsetX, offsetY, width, height, layer, mobile, tablet }, set-element-order { instanceId, group, order }; theme sections take areaId. Custom blocks are not editable inside.
Rebuilding a site ("make it look like this"): read get_custom_code (allowed.recreate, allowed.motion), start from set-theme { presetId: "blank-canvas" }, build each band as a section, remove-section replaced theme sections.
Behaviour tools (heatmaps, recordings, funnels): list_behaviour_tools, then set_behaviour_tool { tool, id } with the id the merchant copies from the tool (never invented); live on publish_storefront. get_behaviour_insights reads dead clicks, rage clicks and scroll depth once the merchant pasted an export token.
Own heatmaps: set_heatmaps, list_heatmap_pages, get_heatmap.
Sister stores of one owner: list_store_group, compare_store_group (verdicts included), start_strategy_store; who came: get_audience.
In-store tests: upsert_experiment, start_experiment, get_experiment (repeat its verdict), end_experiment.
Everything else (catalog, images, SEO, shipping, discounts, flows) is live at once.
Catalog, pages, navigation, SEO
upsert_product (full product each time; slug is the identity; up to 3 options and 100 variants) then set_product_image (primary, gallery or variant); upsert_collection (manual productIds or tag rules) then set_collection_image. Images come from an https URL or inline base64 (upload_image keeps one for later).
Content pages and header/footer links are draft edits (set_content_pages, set_navigation); store-wide SEO is live (set_seo).
Shipping
Three modes, chosen with set_shipping { mode }: self (flat rates, the merchant ships), formahand (Formahand Shipping: get_shipping_label_rates → buy_shipping_label from a paid order, billed on the monthly statement, tracking included) or a courier platform id from list_integrations (connect_integration with the merchant's credentials, test_shipping_rates to check). get_shipping returns the settings and the modes this deployment offers. Labels need the ship-from origin (with email and phone) and, live, the merchant's payment profile.
Orders ship with fulfill_order (with lines it ships part: partially_fulfilled until the last line) or set_order_tracking; get_order_tracking refreshes where a parcel is; add_order_note and cancel_order do the rest. refund_order needs the orders:refund scope and the owner's refund limit; over it, a refunds limit wall.
Payments
One active processor per store and environment: Formahand Payments (card payments; the merchant finishes payouts at /dashboard#settings/payments/setup), PayPal (their own REST app keys) or manual (bank transfer / cash on delivery). get_started reports whether payments are ready; an agent cannot complete onboarding or enter card details. get_payout_profile → set_payout_profile fills that form ahead so it opens asking only for identity, bank and terms; leftover stays with the merchant. Discounts need a ready Formahand Payments account. Shoppers abroad can pay in their own currency (local_currencies payment feature, off by default); prices stay in the store currency.
Events → subscriptions → flows → templates
Every change is an event <resource>.<verb> (describe_events for the vocabulary and filter grammar, list_events / get_event to read). create_subscription sends matching events as signed CloudEvents to a webhook or agent endpoint (the envelope carries mode); list_deliveries / retry_delivery follow them.
Flows react inside the store without a server: describe_flows → create_flow (topics + filter + ordered actions: send_email with a store template, notify_merchant, tag_order, webhook, wake_agent), test_flow dry-runs, set_flows_enabled is the kill switch. Email templates: list_email_templates, upsert_email_template, preview_email_template, reset_email_template; editing a system key changes what the platform sends.
A flow may also run on a clock (schedule: every 15m/1h/6h/1d, optional at/timezone) or expose an inbound URL (create_flow_hook, bearer or HMAC). set_secret keeps a write-only credential, usable only as {{ secrets.name }} in a webhook url or header or a wake_agent bearer; logs show ***.
Customers and marketing
list_customers (q, tag, minOrders, minSpentMinor, marketingStatus), get_customer, tag_customer and export_customers (own scope, CSV) are the CRM. Campaigns are draft → preview → send: create_campaign → preview_campaign → send_campaign, which meets the marketing wall (describe_marketing, or describe_readiness { feature: "campaigns" }). Only subscribed contacts are mailed, and a token can never subscribe someone or accept the terms.
Before you act: readiness
Anything that needs setting up first answers one checklist: describe_readiness { feature? } → { features: [{ feature, label, ready, requirements: [{ id, label, done, detail, href, tool?, activateHref?, severity? }] }] }, over storefront_open, checkout, payment_links, discount_codes, campaigns, transactional_email, shipping_labels and live_promotion. Call it before set_launch_mode, send_payment_link, upsert_discount, buy_shipping_label and promote_to_live: each refuses with 409 and this checklist, not halfway. A requirement's tool finishes it, its href is the page the merchant does, activateHref the feature they switch on; severity: "recommended" never blocks. A new store starts closed, so opening it is the last step.
Walls, statements, rate limits
A refused action returns a wall body { wall: true, feature, reason, plan, requiredPlan?, steps[] }: 402 for reason: plan, 409 for setup, paused or limit. Each step names a dashboard href and, where an agent can act, the tool; a step that switches a paid feature on carries activateHref. The sandbox has no walls.
On any wall, or when a meter nears its limit, call describe_account: the plan, the payment profile, the statement and every feature the merchant can switch on (email package, labels, extra domains, media, a plan for unlimited runs), each with agentCanDo, activateHref and askMerchant, a ready sentence carrying that link. Read paymentProfile.state (none/ready/expired) before suggesting a package; never ask a merchant to pay for something twice. Say the sentence and stop: you never add a payment method, choose a package or pay. describe_walls lists used/limit, get_plan pricing and usage, get_statement one month, start_upgrade opens the page the merchant pays on.
Tokens are rate limited (10 requests/s, bursts of 30 → 429 with Retry-After) and never capped or billed.
Domains
describe_domain_setup { hostname } returns the CNAME records, the detected DNS provider and provider commands with credential placeholders; add_domain registers the hostname (a bare domain brings its www); the agent creates the records on the merchant's machine with their own credentials, never through Formahand; check_domain until active; make_primary_domain if wanted. Store email rides on the same DNS: set_sender → records → verify_sender → send_test_email; unverified, a store sends no mail. Live only.
Promote and refresh
promote_to_live copies from the sandbox to live: products with variants and images, collections, pages, navigation, SEO, shipping settings and rates, discounts (recreated on the live payment account), email templates, flows (disabled on arrival, secrets blank) and integration configs without secrets. The storefront arrives as a new live DRAFT revision: nothing shoppers see changes until publish_storefront. Never copied: orders, customers, events, subscriptions, labels, statements. Idempotent by slug, handle, key, name and code. refresh_sandbox goes the other way, publishing in the sandbox.
Custom sites and plugins
A merchant may keep Formahand as the backend behind their own site: the public read API on the store hostname (/api/storefront/products, /collections, /pages, /navigation, no token, CORS open) gives the catalog; a page on an origin listed with set_checkout_origins starts checkout with POST https://formahand.com/api/checkout { storeId, items, successUrl, cancelUrl } and follows the returned url; the thank-you page reads /api/storefront/orders?session_id=. Orders, emails, stock and the dashboard stay with Formahand. Plugins act with scoped tokens and create_subscription. Full guide: https://formahand.com/docs/headless.
Custom data, rules and kinds
Custom fields (set_custom_fields, namespaced keys like supplier.order_id on any object) hold anything else the store needs and appear in views and events. Products have a kind (physical, digital with expiring download links, service) and a backorder flag. Pricing, shipping and payment rules are checkout data (upsert_pricing_rule, upsert_checkout_rule, customer groups); check one with simulate_checkout first. Missing a feature (free gifts, bundles, personal codes)? Build it as store code: describe_store_functions.
Operating the store
Shoppers sign in with magic links (revoke_shopper_sessions if needed). get_analytics (sessions, bounce and the funnel), get_product_performance (which product is paid-ready) and get_search_performance measure what changed; get_seo_insights proposes fixes; set_seo takes per-owner overrides, slug changes write redirects, posts render at /blog with RSS.
Channels and measurement
describe_connections lists the channels a store can connect (Pinterest today) with an askMerchant sentence and the dashboard link when the merchant must act in a browser; list_connections (settings:read) never shows keys; disconnect_connection { id } (settings:write, ask first) removes the keys, not what was published.
publish_pin { productId } (products:write) pins a product behind a tracked link; list_channel_posts and get_post_performance read the pins, their numbers and their sales; sync_catalog { provider: "pinterest" } sends the catalog. Not connected: 409 with askMerchant.
set_pixel { channel, id } writes a channel code; set_pixel { channel, serverToken, serverEnabled: true } with the token the merchant pastes (channels[].serverTokenLabel, never invented) turns on server events, then send_test_event { channel }.
Automation without a helper server
Flows react to events, run on a schedule (schedule: { every, at?, timezone? }) or receive an inbound hook (create_flow_hook: bearer or HMAC, per-flow URL). set_secret stores write-only secrets usable only in webhook URLs/headers and agent bearers; outbound URLs to private ranges or our own zone are refused. install_recipe sets up a documented pattern (dropshipping, print on demand, nightly report, low stock, chat alerts, sheet sync), disabled until the owner enables it.
How the owner stays in control
Tokens can expire, be paused automatically after too many writes or errors per minute, and run in propose mode where every write becomes a proposal the owner approves (list_proposals). Everything a token does is in the event log under its actor id. Refunds above the agent limit, plan changes, payment onboarding, team changes, exports and store deletion stay with the owner.
Never possible with a token
Minting or revoking tokens, changing team members, accepting terms, payment onboarding, the payment profile, paying a statement, setting the agent refund limits, changing a token's own governance or approving its proposals, choosing the dashboard's environment, exporting the whole store and closing it: session-only routes refuse tokens with 401 or 403. Everything else is token work. A secret comes back once, from the call that creates it (secretShownOnce); reads report only secretSet.
Recommended order of operations
New store: get_started → get_store → set_shipping → per product upsert_product + set_product_image (and upload_product_file when the kind is digital) → upsert_collection + set_collection_image → set_seo, store-wide and then per owner → the draft edits above → publish_storefront → describe_readiness and describe_walls for what is left, then set_launch_mode { access: "open" }.
Then: install_recipe rather than hand-building flows; simulate_checkout on a cart the rule should catch and one it should miss before enabling any upsert_pricing_rule, upsert_checkout_rule or upsert_customer_group; get_analytics { compare: true } before and after, so a change is measured.
Try in the sandbox, then promote: with an fh_test_ token enable_sandbox → poll get_environment → build and publish_storefront there → place a test order on the sandbox hostname (test cards) → promote_to_live { dryRun: true } → promote_to_live → with an fh_<mode>_ token get_storefront_draft → publish_storefront.
The section above is word for word what the MCP initialize instructions, the describe_system tool and /llms.txt carry, so the four never diverge.
Tokens and scopes
Settings → Agents & API mints tokens. A token looks like fh_<mode>_ + 32 random bytes (base64url, 51 characters in total); a sandbox token is fh_test_ + the same and only ever reaches the store's test environment (environments). The prefix is the environment: there is no per-call switch, and POST /api/tokens { mode: "test" } chooses it at mint. Only the SHA-256 hash and the first 12 characters are kept; the plaintext is shown once. Tokens are unlimited on every plan (a hard ceiling of 200 active tokens per store stops runaway scripts); names are 1–60 characters. Revoking is immediate. last_used_at is refreshed at most once a minute. Calls are never capped or billed; every call counts on the store's monthly meter and its activity clock (billing §4, §8), and the rate limits are in Walls, statements and rate limits below.
Send it as Authorization: Bearer fh_<mode>_… to the same /api/* routes the dashboard uses. Bearer requests skip the same-origin CSRF check (a cross-site form cannot attach the header) and skip the membership-role lookup on the storefront editor (tokens are minted by owners and admins and carry their own scopes). The token's store is the only store it can reach; a storeId that does not match is refused.
There are twenty-one scopes. A token carries any subset; describe_system reports the ones this token holds.
| Scope | What it opens |
|---|---|
storefront:read | GET /api/storefront-editor, /api/pages, /api/navigation, /api/seo (store-wide and per-page overrides, ?list=1), /api/redirects, /api/posts |
storefront:write | PATCH/PUT /api/storefront-editor, writes to /api/pages, /api/navigation, /api/seo, /api/redirects, /api/posts (including a post's cover image) |
storefront:publish | POST /api/storefront-editor ({ revision, action: "publish" }) — the only way anything reaches shoppers |
products:read | GET /api/products, /api/collections, /api/custom-fields, /api/product-files |
products:write | writes to /api/products, /api/collections, /api/product-files, /api/custom-fields, and /api/product-images (multipart image, JSON { url } for a server-side import of a public https image, or JSON { dataBase64, contentType } with the bytes inline; productId is optional — the upload is not tied to a product) |
orders:read | GET /api/orders (JSON, or ?format=csv for the bookkeeping export), GET /api/shipping/labels?orderId=, GET /api/analytics (and the search-performance read) |
orders:write | POST /api/orders (create an order the merchant sells themselves), POST /api/order-actions (fulfil — whole order or lines — cancel, note, tracking, mark paid, send a payment link), POST /api/shipping/labels (quote, buy, void a Formahand Shipping label), POST /api/product-files?orderId= (re-issue download links) |
orders:refund | the refund action on POST /api/order-actions and the refund_order tool, on top of orders:write — it moves the merchant's money and obeys the agent refund limits |
settings:read | GET /api/shipping, /api/shipping/formahand, /api/discounts, /api/integrations, /api/webhooks, /api/checkout-origins, /api/subscriptions, /api/subscriptions/:id/deliveries, /api/deliveries, /api/events/echo, /api/flows, /api/flows/:id, /api/flows/settings, /api/flows/recipes, /api/flows/describe, /api/flow-hooks, /api/secrets, /api/email-templates, /api/email-templates/:id, /api/recipes, /api/pricing-rules, /api/checkout-rules, /api/plan (plan, effective pricing, walls, usage, catalog, the running statement, the referral link; ?statement=YYYY-MM for a month), /api/environment |
settings:write | writes to all of the above: shipping, discounts, integrations (/api/integrations/test too), webhooks, checkout origins, subscriptions (POST /api/subscriptions/:id/test, POST /api/deliveries/:id/retry), flows (POST /api/flows/:id/test), flow hooks, secrets, email templates (preview, reset), POST /api/recipes/install, pricing and checkout rules, customer groups, the search-performance import, POST /api/plan ({ action: "upgrade" | "portal" | "sync" | "promo" | "reactivate" }) and POST /api/environment ({ action: "enable" | "promote" | "refresh" }; select is session-only) |
events:read | GET /api/events, /api/events/:id, /api/events/vocabulary, /api/flows/:id/runs, the schedule-tick runs (read-only: tokens cannot write the log) |
domains:read | GET /api/domains (also ?describe=<hostname>, ?connect=<id>) — see domains |
domains:write | POST /api/domains ({ hostname, www? }, { action: "check", id }, { action: "make-primary", id }), DELETE /api/domains?id= |
customers:read | GET /api/crm (search q, and tag, minOrders, minSpentMinor, marketingStatus, limit, cursor), GET /api/campaigns (audience, the marketing wall, and campaigns: this environment's stored campaigns, newest first, at most 50, the same shape as list_campaigns), GET /api/customer-groups |
customers:write | POST /api/crm (upsert one contact by email: name, phone, marketing status, tags, notes), putting a customer in a group, and ending a shopper's sign-in sessions |
customers:export | GET /api/crm?format=csv — on top of customers:read, because it takes the whole audience away as a file |
marketing:send | POST /api/campaigns: stores the campaign in the list list_campaigns reads and sends it once per idempotencyKey (a repeated key returns the stored outcome), walled; the answer carries id, the stored campaign's id (see email-marketing) |
gift_cards:issue | POST/PATCH /api/gift-cards: putting store credit in the till, stopping a card, moving a balance. Nothing else implies it — not orders:write, not settings:write — because issuing credit is issuing money, and it obeys the same money limits a refund does (payments § Gift cards). Reading the till is orders:read. |
discounts:issue | POST /api/discounts/mint: single-use codes made from a template discount (checkout-rules "Codes made from a template"). Nothing else implies it, not settings:write: each code is money off, so minting is its own line, capped per day per caller and per store. |
payments:write | PUT /api/payments/features, POST /api/payments/tax-registrations, POST /api/payments/payouts: the switches on the store's card-payments account, the tax registrations behind them, and sending its balance across. Nothing else implies it — not settings:write — because two of those switches change what every order costs the merchant and one of them moves their money out the same minute (payments) — and because a tax registration is a claim about the merchant's own obligations that binds whoever minted the token. Reading the same switches is settings:read. |
email:send_template | POST /api/app-emails: one of the store's own templates, to one recipient, from the store's own sender. It is refused for every ordinary token — only an installed app's own token reaches it, and only when the merchant ticked Send one of your own emails on the install screen (apps §3). |
Custom fields on an order, order_line or customer need the matching orders:* / customers:* scope as well as products:* (custom-fields).
Session-only routes refuse tokens: /api/tokens, /api/team, /api/payments/connect, /api/onboarding, /api/sender, /api/consent answer 401 ("Sign in to …"); /api/setup answers 403. The complete list, with the reason for each, is Never possible with a token. Missing or revoked tokens answer 401; a token without the route's scope answers 403 on the routes that report it (/api/storefront-editor, /api/webhooks) and 401 elsewhere. Every write a token makes is recorded in the store's event log with the actor token:<id> (see events §3).
MCP endpoint
POST https://formahand.com/mcp speaks the Model Context Protocol over Streamable HTTP with plain JSON responses (protocol versions 2025-03-26 and 2025-06-18; no SSE, no sessions, no dependencies). GET answers 405; a missing or invalid bearer answers 401 with WWW-Authenticate: Bearer. JSON-RPC batches are accepted; notifications get 202.
Connect Claude Code:
claude mcp add formahand --transport http https://formahand.com/mcp --header "Authorization: Bearer <token>"
Generic config: { "mcpServers": { "formahand": { "type": "http", "url": "https://formahand.com/mcp", "headers": { "Authorization": "Bearer <token>" } } } }.
Every tool needs a scope; calling one the token lacks returns a tool result with isError: true (not an HTTP error), so an agent can explain what it needs. Bad arguments are JSON-RPC -32602. Refusals from the store itself (validation, a stale revision) come back as isError results carrying the store's own message.
Every tool reaches the store through exactly the route the dashboard uses for the same job, so no tool can skip a check the dashboard meets. Every tool description says whether it changes live data or only the storefront draft, which environment it acts on (the token's, or live only for domains, plan and billing), whether it is read-only, and the follow-up tool when there is one; the annotation is part of every tool, so no description can omit it. The describe_* tools all answer the same shape: { overview, vocabulary, examples[] } plus their own fields.
Tool catalogue
Grouped by the job they do. The list is kept honest in both directions: every scoped /api/* route maps to the tools that do its job, or is listed with the reason it has none. "any" in the scope column means a token of any shape may call it.
Orientation (3)
| Tool | Scope | Does |
|---|---|---|
describe_system | any | The generated guide (the same text as the initialize instructions) as { overview, summary, vocabulary: { sections, modes, tokenPrefixes, thisToken, promoteCopies, promoteNever, tools }, examples } |
get_started | any | This store's state (mode, runtime, products, published, payments, shipping, domains, sandbox, analytics, rules, recipes, schedules, shopper sign-in, pending proposals, open walls) and an ordered checklist steps: [{ id, done, tool, args?, why }] with next; re-run after each step |
get_store | any | id, name, mode (live | test), that environment's hostnames, runtime status, theme { id, name } (the look the draft wears; themePresetId is the older field), live publication version, whether a sandbox exists |
Storefront draft, brand, custom code, history and publishing (15)
| Tool | Scope | Does |
|---|---|---|
list_modules | storefront:read | describeStorefrontModules() plus the JSON schema of editorCommandSchema: every command and module setting the draft accepts |
get_storefront_draft | storefront:read | { schemaVersion: 3, draft: { revision, document, publishedRevision }, liveVersion, modules }: the document is schema version 3, every page and slot as section instances (the store's own sections included) with layout.pins; every storefront write answers in the same version |
apply_storefront_commands | storefront:write | { revision, commands } (at most 30) → the new draft; a stale revision is a conflict, re-read and retry. The batch is atomic, and a refusal names the command: Command 3 (set-section-visibility, collection): the home page has to show something…, with command: { index, type, target }, position (the 0-based array index) and reason. Editor rules: hide a theme section with set-section-visibility or take it off the page with remove-section (add-section { sectionId } puts it back, once), never with CSS; the home page has to keep one visible section or block (home_empty); setting a section to the visibility it already has is a no-op, text colours must stay readable; get_custom_code's allowed.editor lists them all. A notes array comes back when a command succeeded but did something less than asked — today, a text colour dropped on set-theme because it could not be read on the new theme |
set_hero_image | storefront:write | { revision, url | dataBase64 + contentType }: stores the image, then applies set-hero-image to the draft → { imageUrl, draft } |
set_logo | storefront:write | { revision, url | dataBase64 + contentType, alt?, width? }: stores the image, then applies set-logo → { imageUrl, draft }. All six themes render it; apply_storefront_commands with { type: "set-logo", url: "" } removes it. The raw command only accepts the store's own /media/products/… path, so upload first (this tool does) |
set_favicon | storefront:write | { revision, url | dataBase64 + contentType }: the browser-tab icon, a square PNG → { imageUrl, draft }. Every store already has a generated one (its first character on its accent colour); .ico and .svg are not supported |
set_navigation | storefront:write | { revision, navigation }: header and footer links as a draft edit |
set_content_pages | storefront:write | { revision, pages }: the store's Markdown content pages as a draft edit |
publish_storefront | storefront:publish | { revision } — the only way anything shoppers see changes |
list_storefront_versions | storefront:read | { limit? } → every save and publish, newest first, with who made it and which sections, pages and areas changed |
restore_storefront_version | storefront:write | { id } writes that snapshot back as the draft with a new revision; it never publishes, and dangling lists what the old version points at that is gone |
get_custom_code | storefront:read | The store's own code: { blocks: [{ id, where, index, sectionId, placement, pageSlug, width, visible, htmlBytes, cssBytes }], customCss, embeds, providers, meter, revision }. Call it before the three writers below |
set_custom_block | storefront:write | { revision, id?, html?, css?, position?, sectionId?, placement?, pageSlug?, width?, visible?, remove? }: one HTML block in the draft, placed anywhere with position (see "Page layout" below), rebuilt from an allowlist on write and again on render. No id adds one; remove: true deletes it |
set_theme_css | storefront:write | { revision, css }: the store's own stylesheet in the draft, scoped under the storefront root before it renders; "" removes it. Answers with removed, the lines the allowlist refused |
set_script_embeds | storefront:write | { revision, embeds: [{ provider, id, enabled? }] }: analytics and chat tags from a fixed provider list; you supply only the identifier and Formahand renders the tag. Free-form JavaScript is never accepted |
Sections (9)
| Tool | Scope | Does |
|---|---|---|
describe_sections | storefront:read | Everything needed to write a section package, as data: the fields, every setting type, the template language with an example of each tag, object and filter, the limits and the rules. { parts: ["schema" | "errors" | "example"] } answers the JSON schema, every refusal code or a complete package instead |
list_sections | storefront:read | { origin?, cursor?, limit? } → the built-in sections and the store's own, add-on and catalogue ones: { id, name, version, origin, editable, versions, placed } with nextCursor |
get_section | storefront:read | { id, version?, part?, offset? } → the whole package, editable, the kept versions and where it is placed; a template or styles too long for one answer is read with part |
save_section | storefront:write | { package } creates a section or saves its next version, checked whole; leave version out and it is chosen from what changed. Answers { section, version, previousVersion, bump, changes, draft, next }; a refusal lists every problem as Section <id>, setting <key>: … with its code |
preview_section | storefront:read | { id, version?, settings?, page? } or { package, settings?, page? } → { html, bytes, refusals? }: one instance rendered as shoppers would see it, placed nowhere |
fork_section | storefront:write | { id, newId, repoint? }: the store's own copy of an add-on, catalogue or store section at 1.0.0; repoint: true moves the draft's instances onto it |
list_section_versions | storefront:read | { id } → the kept versions, newest first, and which one the draft and the live storefront render |
rollback_section | storefront:write | { id, version } saves that version's content as the next version; the draft moves to it |
delete_section | storefront:write | { id, force? }: refused while placed, naming where; force: true leaves each instance in place showing nothing |
Themes (6)
| Tool | Scope | Does |
|---|---|---|
list_themes | storefront:read | Every look, one list: { themes: [{ id, name, category, line, pages, preview, phone, licence, current?, demoUrl }], current }; preview and phone are pictures of its home page; demoUrl: a hosted demo store of the theme, open to visitors, or null; licence: { name, url, attribution, author, source? }. Pick one with set-theme { presetId: id } in apply_storefront_commands. library: [{ code, name, blurb, previewUrl, hostname }], showcase looks Formahand listed, taken with claim_showcase_look { code } |
get_theme | storefront:read | { id } → one theme whole: theme (line, pages, pictures, what it needs) and its sections (summarised), every page's instances with their settings, images, menus, starter pages and licence ({ name, url, attribution, author, source? }) |
list_presets | storefront:read | The older name for list_themes: the same looks by catalogue id, { presets: [{ id, name, description, category, version, theme, ofTheme?, typefaces, colours, sections, pages, starterPages, licence }] } |
get_preset | storefront:read | The older name for get_theme: the same answer |
apply_preset | storefront:write | The older way to pick a theme, kept for dryRun and reset: { id, dryRun?, revision?, reset? } gives the draft the theme (an original theme keeps your colours, typefaces, copy, stylesheet and menus; sections the store already holds are reused, reset: true restores the originals as new versions; any other theme replaces the colours, typefaces, stylesheet, layout and menus), adds its sections and placeholder images to the store and starter pages where the slug is free, and keeps your own sections at the end of their page, hidden. The draft before is kept as "Before preset <name>". dryRun: true reports the same and writes nothing (read scope). Never publishes |
claim_showcase_look | storefront:write | Live store only. { code } → { claimId, applied }: puts a showcase look from the library (list_themes library) on this store's draft — storefront, sections, pages and menus; the store's products and orders are never touched. applied: false means the copy lands once the store is ready; a repeat answers the same claim. Owner or admin tokens only. Never publishes |
Catalog, images and media (15)
| Tool | Scope | Does |
|---|---|---|
list_products | products:read | A page of products with variants, images, kind, backorder and custom fields: { products, limit, nextCursor }, up to 1000 by default and 5000 with limit. A non-empty nextCursor means there is more — pass it back as cursor |
get_product | products:read | One product by id or slug, plus a digital product's files |
upsert_product | products:write | { product } — the whole product each time; slug is the identity; up to 3 options and 100 variants; carries the product details below (vendor, type, barcode, cost, size, customs, tax category, unit price) and related (up to 8 product ids for "You may also like"); validated against the store's own product schema |
list_collections | products:read | Collections with their members |
upsert_collection | products:write | collectionInputSchema body: manual productIds or a tag rule |
delete_collection | products:write | { id } |
set_product_image | products:write | { productId, url | dataBase64 + contentType, slot?: "primary" | "gallery" | { variantId }, alt? }: stores the image, then PUTs the product with it set; live |
set_collection_image | products:write | { collectionId, url | dataBase64 + contentType } → { imageUrl, collection }; live |
upload_image | products:write | { url | dataBase64 + contentType, alt? } → { imageUrl, alt }: stores an image without attaching it, for anywhere an https image URL is accepted |
upload_section_video | products:write | { dataBase64, contentType: "video/mp4" | "video/webm" } → { key, mediaUrl, path, contentType, bytes }: stores a signature-checked video up to 50 MB in this environment's media library |
delete_product | products:write | { id | slug } → { deleted }; a product with stock reserved by an open checkout is refused (archive it instead) |
bulk_products | products:write | { ids (≤ 100), action: "archive" | "activate" | "delete" } → { updated, deleted, skipped } — the dashboard's bulk bar |
export_products | products:read | { status? } → the catalog as CSV text, one row per sellable thing, in the importer's own shape; whole rows only, with rows, total and truncated |
import_products | products:write | { csv, dryRun?, onExistingHandle? } — the migration wizard's Shopify-aware parser. dryRun: true reports every row as create, update or skip, lists the exact fields an update would change, and writes nothing. onExistingHandle: "update" merges: only the columns the file carries are written; variants are merged one at a time (matched on SKU, else on option values) so a variant's stock, cost and barcode survive a price sheet, a variant the file omits is kept unless removeMissingVariants: true, and each variant is claimed once — a second row carrying the same SKU or option values is refused with a reason rather than overwriting the first, and a file with such a row removes nothing at all even with removeMissingVariants. ≤ 2 MB and ≤ 500 products a call |
list_media | products:read | Every image in the store's media library with its size and usedBy; inUse: false is an orphan still on the media meter |
delete_media | products:write | { key, force? }: removes one object; an image something still points at is refused with the list of places that use it |
Product kinds and downloads (4) — product-kinds
| Tool | Scope | Does |
|---|---|---|
list_product_files | products:read | { productId } → { files: [{ id, name, bytes, contentType }] }; where the file itself is kept is never returned — a file is only ever handed out as a signed download link |
upload_product_file | products:write | { productId, url | dataBase64 + contentType, name? }: pdf, zip, epub, mp3, mp4, png, jpg, txt, csv, json; ≤ 200 MB by URL, ≤ 5 MB inline, ≤ 10 per product |
delete_product_file | products:write | { productId, fileId }: removes the file and the object; links already issued stop working |
regenerate_download_links | orders:write | { orderId } → { downloads: [{ name, url, expiresAt }] }, a fresh 72-hour set for a paid order |
Custom fields (4) — custom-fields
| Tool | Scope | Does |
|---|---|---|
describe_custom_fields | products:read | The six owner types, the seven value types with shapes and limits, namespace rules, merge semantics, scopes, where fields appear and what promote copies; call it before writing |
list_custom_fields | products:read | { ownerType, ownerId } → the object's fields, ordered namespace then key |
set_custom_fields | products:write | { ownerType, ownerId, fields: [{ namespace, key, type, value }] } — merges; fields you do not send are kept |
delete_custom_field | products:write | { ownerType, ownerId, namespace, key } |
SEO, redirects and posts (12) — content-seo
| Tool | Scope | Does |
|---|---|---|
get_seo | storefront:read | Store-wide SEO, or one object's override with { ownerType, ownerId } |
set_seo | storefront:write | { title, metaDescription, socialTitle, socialDescription, noindex } store-wide, or { ownerType, ownerId, title?, metaDescription?, canonical?, ogImageUrl?, noindex? } for one product, collection, page or post; live |
list_seo_overrides | storefront:read | Every per-object override with the URL it applies to |
get_seo_audit | storefront:read | Search problems with a plain fix and the dashboard page for each; changes nothing |
get_seo_insights | orders:read and storefront:read | Topics to write about, titles to improve, duplicate pages, pages to remove and answer readiness, each with its evidence and the screen that fixes it; one part at a time; changes nothing |
list_redirects | storefront:read | { redirects: [{ id, fromPath, toPath, status, hits, createdAt }] } |
set_redirect | storefront:write | { fromPath, toPath, status? }: a store path, or an https URL to send people off-site |
delete_redirect | storefront:write | { id | fromPath } |
list_posts | storefront:read | The store's posts, newest first, drafts included |
upsert_post | storefront:write | { id?, slug, title, excerpt?, bodyMarkdown?, coverImageUrl?, author?, tags?, status }; publishing stamps publishedAt once |
delete_post | storefront:write | { id } |
set_post_image | storefront:write | { postId, url | dataBase64 + contentType }: a cover kept in the store's media library |
Tracked links (3) — tracked-links
| Tool | Scope | Does |
|---|---|---|
list_tracked_links | storefront:read | { limit?, offset?, id?, code? } → links with url (https://<hosts[0]>/l/<code>), clicks (bots excluded), orders and revenueMinor in currency (only when salesKnown), total, nextOffset |
upsert_tracked_link | storefront:write | { id?, code?, name, targetPath, channel?, source?, medium?, campaign?, discountCode?, active? }; with id only given fields change; the code is fixed once made; up to 500 a store |
delete_tracked_link | storefront:write | { id | code }: the short address then leads home and its click history is removed |
Orders (9)
| Tool | Scope | Does |
|---|---|---|
list_orders | orders:read | { status?, fulfillment?, q?, limit?, cursor? } → { orders, limit, nextCursor }. The store filters, searches and pages; page with nextCursor rather than fetching everything |
get_order | orders:read | One order with items (each with fulfilledQuantity), tags, timeline, custom fields and downloads, and cameFrom |
fulfill_order | orders:write | { id, carrier, trackingNumber, trackingUrl, notifyCustomer, lines? }; lines: [{ lineId, quantity }] ships part of the order |
refund_order | orders:refund | { id, amountMinor?, reason?, restock? } → { order, refund }; without amountMinor the whole remainder, and restock: true puts the goods back (off by default). Over the owner's agent refund limit the answer is a refunds limit wall |
cancel_order | orders:write | { id, reason? }: cancels an order nothing has shipped from, returns stock, closes an open checkout; the reason is written to the note first |
add_order_note | orders:write | { id, note } replaces the internal note |
tag_order | orders:write | { id, tags } replaces the order's own labels; the buyer never sees them |
export_orders | orders:read | { from?, to?, status? } → the order CSV as text, one row per order item. Walks the order book page by page, filtering as it goes and fetching a detail only for an order that is actually in the file; ≤ 500 orders and ≤ 5 MB a call, with matched beside orders so truncated is never a guess |
print_packing_slip | orders:read | { id } → the HTML the dashboard prints, escaped and ready to save or send |
Store profile (2)
| Tool | Scope | Does |
|---|---|---|
get_store_profile | settings:read | { storeName, slug, legalName, category, currency, phone, supportEmail, addressLine1…addressCountry, timezone, weightUnit } — what labels, packing slips, the storefront footer and the store's structured data read |
set_store_profile | settings:write + owner | Any subset of those fields. The key has to have been minted by a store owner; a staff member's key is refused. Renaming updates the storefront at once and never changes the free <slug>.store-on-formahand.app address; currency is what a new product is created in and converts nothing; timezone is the IANA name analytics days are cut on; weightUnit is display and input only — every weight is still stored in grams |
Shipping and tracking (7) — shipping
| Tool | Scope | Does |
|---|---|---|
get_shipping | settings:read | { allowedCountries, rates[], origin, parcel, mode, modes } — and which modes this deployment offers |
set_shipping | settings:write | The same shape plus an optional mode: self (flat rates), formahand (Formahand Shipping) or a courier platform id from list_integrations |
get_shipping_label_rates | orders:read | { orderId } → Formahand Shipping rates for the order (cost + markup, the shopper's choice flagged) and the current label if any |
buy_shipping_label | orders:write | { orderId, rateId, notifyCustomer? }: charges the store's card, buys the label, fulfils the order, starts tracking, emails the buyer |
void_shipping_label | orders:write | { orderId }: asks the carrier to cancel; the card is refunded once confirmed (refund_pending → refunded) |
get_order_tracking | orders:read | { orderId }: refreshes and records tracking through the order's source (Formahand label, the store's courier platform, our tracking service) → { order, tracking, source }; links-only orders return tracking: null with a notice |
set_order_tracking | orders:write | { orderId, carrier, trackingNumber, trackingUrl?, notifyCustomer? } for a parcel you ship yourself; fulfils the order if needed and emails the buyer |
Customers and marketing (15) — email-marketing
| Tool | Scope | Does |
|---|---|---|
list_customers | customers:read | { q?, tag?, minOrders?, minSpentMinor?, marketingStatus?, limit?, cursor? } → { customers, nextCursor, total }, each with accountStatus and lastSeenAt |
get_customer | customers:read | One contact by id or email, with their orders, timeline, addresses, stats, notes, tags, customerGroup and accountStatus |
add_customer_note | customers:write | { customerId, note } — a line on that customer's timeline |
upsert_customer | customers:write | { email, name?, phone?, tags?, notes?, marketingStatus? }: creates or updates one contact by email, merging with what is there. subscribed is only ever kept, never granted |
tag_customer | customers:write | { id | email, add?, remove? } — tags only; consent is never touched |
list_segments | customers:read | The four built-in segments and the store's saved ones, with today's member counts |
upsert_segment | customers:write | { id?, name, filter } — a saved filter, never a copy of the list |
delete_segment | customers:write | { id }; the contacts in it are untouched |
preview_segment | customers:read | { filter } or { id } → { count, customers } (the first 10); writes nothing |
export_customers | customers:export | { format: "csv", tag?, marketingStatus? } → { filename, csv, rows, truncated, note }, capped at 5 MB |
describe_marketing | any | The wall's conditions, the caps and warm-up, how consent works, what a token can and cannot do |
list_campaigns | customers:read | Drafts and sends of this environment |
create_campaign | marketing:send | { subject, previewText?, bodyHtml | bodyMarkdown, audience: { tag?, segmentId?, marketingStatus: "subscribed" } } → a draft; nothing is sent |
preview_campaign | customers:read | { id } → the draft, the audience, the recipients of the next send, and the marketing wall as it stands |
send_campaign | marketing:send | { id } sends through the marketing wall, at most 50 recipients per call |
Checkout rules, pricing rules and groups (10) — checkout-rules
| Tool | Scope | Does |
|---|---|---|
list_pricing_rules | settings:read | The store's automatic discounts with conditions, priority and use counts |
upsert_pricing_rule | settings:write | percent, amount, bxgy or free_shipping, with applies, priority and stackable |
delete_pricing_rule | settings:write | { id }; orders already placed keep the discount they were given |
list_checkout_rules | settings:read | The store's shipping and payment rules |
upsert_checkout_rule | settings:write | hide_rate, show_rate_only, hide_payment or pickup |
delete_checkout_rule | settings:write | { id }; carts go back to seeing every rate |
list_customer_groups | customers:read | Groups with their price list, minimum order, tax flag, terms and member count |
upsert_customer_group | settings:write | A handle, a price list ({ "<productId>": priceMinor } or { "%": -20 }), minOrderMinor, taxExempt, terms |
set_customer_group | customers:write | { customerId, groupHandle | null } |
simulate_checkout | settings:read | { items, customerEmail?, destinationCountry? } → the priced lines, the rules that would apply, and the shipping and payment options. Creates no order, holds no stock, counts no usage |
Checkout settings, tax, staff notifications and the weekly summary (8) — payments
| Tool | Scope | Does |
|---|---|---|
get_checkout_settings | settings:read | { checkout: { phone, company, addressLine2, orderNote, marketingOptIn, termsLink, tipping } } |
set_checkout_settings | settings:write | Replaces every field; read first and send the whole object back. Takes effect on the next checkout |
get_tax_settings | settings:read | { tax: { enabled, pricesIncludeTax, defaultTaxCode, shippingTaxCode } } |
set_tax_settings | settings:write | Replaces every field. Changes what shoppers pay, so read describe_readiness first |
get_notification_settings | settings:read | { notifications: { newOrderEmails, lowStockEmails } } |
set_notification_settings | settings:write | Up to 5 extra addresses per email; the owner keeps receiving them whatever the lists say |
get_weekly_summary | settings:read + orders:read | { week? } (a Monday within the last 12 full weeks, default the last one) → the numbers the Monday mail is built from, the caller's own enabled, nextSend, lastSend and the one suggestion. Read-only, never sends mail |
set_weekly_summary | settings:write | { enabled } for the person the token acts for; owners and admins only |
Store email sending (4) — email-marketing
Formahand never sends a store's mail from formahand.com. Until a store has a verified address on one of its own live domains, its order confirmations, shipping updates and sign-in codes go out as the store over Formahand's shared store address ("the example store" <no-reply@store-on-formahand.app>, replying to the merchant) — get_sender reports that as sending: { kind: "shared", from, replyTo, summary, campaignsReady: false }. Campaigns always need the merchant's own verified domain. Verifying one switches every message over on its own. Live store only.
| Tool | Scope | Does |
|---|---|---|
get_sender | settings:read | { sender: { fromName, email, domain, status, dnsRecords } | null, verified, pendingRecords, sending: { kind: "own" | "shared", from, email, replyTo, summary, campaignsReady } | null } — sending is the address buyers actually see today |
set_sender | settings:write | { email, fromName }: saves the address and returns the DNS records to create. The address must be on one of this store's live domains (list_domains, add_domain, check_domain); a formahand.com address, a domain that is only pending, and a domain another store already claimed are all refused |
verify_sender | settings:write | Re-checks the published records → verified plus what is still pendingRecords. DNS spreads slowly, so call it again after a minute or two |
send_test_email | settings:write | { to? }: one real message from the verified sender, through the path order confirmations take. Only to the owner or a team member (default: the owner), only once the sender is verified, at most 5 per store per day, and it counts against the store's email allowance |
Create the records with the merchant's own DNS credentials on the merchant's own machine, exactly as for the storefront CNAME: DNS credentials never pass through Formahand.
Shopper accounts (3) — shopper-accounts
| Tool | Scope | Does |
|---|---|---|
describe_shopper_accounts | customers:read | Whether sign-in is on, how many shoppers have accounts, how many signed in this week, and every route |
set_shopper_accounts | customers:write | { enabled }: the dashboard's own switch. Off hides /account and refuses new codes; nothing is deleted |
revoke_shopper_sessions | customers:write | { customerId | email }: ends every session of one shopper, so their next visit asks for a new code |
Analytics (9) — analytics, order-sources
| Tool | Scope | Does |
|---|---|---|
get_analytics | orders:read | { from?, to?, compare? } → revenue, orders, AOV, refunds, abandoned rate, new and repeat customers, best sellers, cookie-free views, top referrers, conversion, and compareTo |
get_top_products | orders:read | { period: "7d" | "30d" | "90d" | "365d" } → the best sellers alone |
export_analytics | orders:read | { period } → one CSV holding the daily series, the best sellers and the referring hosts, told apart by a section column |
get_order_sources | orders:read | { from?, to?, model? } → paid orders and sales by channel, source, campaign and tracked link: period, currency, since, totals, channels, unknown, plus summary sentences and notes. Totals equal get_analytics paidOrders and revenueMinor; each order in list_orders / get_order carries cameFrom; model is last-non-direct (default), last, first, linear or any; the answer carries model and modelNote |
get_product_performance | orders:read | { from?, to?, productId?, groupBy? } → per product: views, added to cart, view-to-cart rate and band (below, average, strong, excellent at 6/8/10 percent), orders, units, revenue, purchasers in 180 days and paidReady, with bySource rows per channel, source, campaign or tracked link, plus summary sentences |
import_search_performance | settings:write | { rows: [{ day, query, page, clicks, impressions, position, engine?, country?, device? }], source? }: rows the owner exported, for an engine the store has not connected or for older history; ≤ 5,000 rows a call, upserted by day, engine, query, page, country and device |
get_search_performance | orders:read | { from, to, groupBy?: "query" | "page" | "engine" | "country" | "device" | "day", engine?, country?, device?, limit? } → the rows grouped, with an impression-weighted position |
describe_search_engines | settings:read | {} → each engine's state, pending requests and what to do next; never a code or a key |
connect_search_engine | settings:write | { engine: "google" | "bing" | "indexnow" } → a request the owner approves on the dashboard (approveAt); refused with a test token |
Shopping feeds (2) — feeds
| Tool | Scope | Does |
|---|---|---|
get_feeds | settings:read | The addresses this store publishes itself at: /feeds/google.xml, /feeds/products.csv, /feeds/products.json, /llms.txt, /.well-known/formahand-store.json, and under channelFeeds the Meta, Pinterest and TikTok catalogue feeds (/feeds/meta.csv, /feeds/pinterest.xml, /feeds/tiktok.csv), plus a summary of the feed as it stands |
check_feed | settings:read | Fetches the published feed and answers { ok, items, inStock, outOfStock, backorder, problems: [{ id, title, field, message }], problemCount }; the same check as Check feed in the dashboard. With { channel: "google" | "meta" | "pinterest" | "tiktok" } it adds health: what that channel would reject or warn about per row, each with a fix and the product's dashboard address (first 50, issueCount for the total) |
Pixels and tracking choice (5)
| Tool | Scope | Does |
|---|---|---|
get_pixels | settings:read | {} → { pixels: [{ channel, id, label?, enabled, updatedAt }], consentMode, effectiveConsentMode, asking, channels, cookie }: consentMode is the owner's choice, effectiveConsentMode and asking what shoppers get (no pixel on: "off", nobody asked); the advertising and analytics channels switched on (Meta, TikTok, Pinterest, Google Analytics, Google Ads, Snapchat, Microsoft Advertising), each channel's id format, its event names and the steps to check it in the channel's own tool |
set_pixel | settings:write | { channel, id?, label?, enabled?, serverToken?, serverEnabled?, testCode?, serverAccountId? }: one id per channel, exactly as the merchant copied it from the channel (never invented); label is the Google Ads conversion label. Formahand writes the channel's code and sends page view, product view, add to cart, begin checkout and purchase, only when the shopper's tracking choice allows |
send_test_event | settings:write | { channel }: queues one purchase-shaped test event with the channel's test code and sends it at once → { eventId, sent, failed, rejected, howToCheck }. Refuses a channel without server events, and a channel without a pasted token with the dashboard link (Pixels > channel > Server events) |
remove_pixel | settings:write | { channel }; removing a channel that has no pixel is not an error |
set_consent_mode | settings:write | { consentMode: "ask-where-required" | "ask-everywhere" }: who sees the Accept and Decline banner before pixels load, once at least one pixel is on. "off" (no banner, shoppers in Europe tracked without asking) is refused for tokens; only the owner chooses it in the dashboard |
Channel connections (3)
| Tool | Scope | Does |
|---|---|---|
describe_connections | none | {} → { providers: [{ id, name, kind, capabilities, usedFor, configured, connection, askMerchant? }], dashboard, connectHow }; connecting and reconnecting are browser steps: relay askMerchant and the dashboard link |
list_connections | settings:read | {} → { connections }; access keys never appear |
disconnect_connection | settings:write | { id }; removes the stored keys, nothing already published on the channel; ask the merchant first |
Posts to channels (4)
| Tool | Scope | Does |
|---|---|---|
publish_pin | products:write | { productId, boardId?, boardName?, title?, description?, imageUrl?, link? } → { pinId, pinUrl, boardId, boardName, linkCode, linkUrl, title, merchantSentence }; the pin's link is a tracked link. Not connected: 409 with askMerchant and dashboardUrl; connecting is the merchant's browser step |
list_channel_posts | products:read | { productId?, provider?, limit? } (limit 100 by default, at most 500) → { posts, total, currency, salesKnown, hosts }; each post has its product, linkUrl, metrics (null until first read) and traffic (null without a tracked link) |
sync_catalog | products:write | { provider: "pinterest" } → { items, batches, batchIds, status, failedItems, errors, country, language, merchantSentence } |
get_post_performance | orders:read | { from?, to?, productId? } (days as YYYY-MM-DD, last 30 days by default) → { period, currency, salesKnown, products, totals, notes }, busiest product first; revenueMinor means something only when salesKnown is true |
Behaviour tools (3)
| Tool | Scope | Does |
|---|---|---|
list_behaviour_tools | settings:read | {} → { tools: [{ id, name, state, enabled, toolId, settings, problem, idLabel, idExample, help, verifyChecklist, ... }], revision, consentMode, warnings, publish, clarity: { tokenSet, dashboard, insights } } |
set_behaviour_tool | storefront:write | { tool, id?, enabled?, settings?, remove?, revision? } → { tools, revision, note, warnings }; id exactly as the merchant copied it from the tool (never invented). Writes the draft; live on publish_storefront |
get_behaviour_insights | settings:read | { days? (1 to 3, default 3), refresh? } → { days, fetchedAt, totals, pages, summary, cached, budget, note }: dead clicks, rage clicks, quickbacks and scroll depth per page. Without the export token the merchant pastes in the dashboard: 409 with askMerchant |
Heatmaps (4) — heatmaps
| Tool | Scope | Does |
|---|---|---|
get_heatmap_settings | orders:read | {} → { enabled, updatedAt, keepDays, collected, dashboard } |
set_heatmaps | settings:write | { enabled } → the settings plus note; the store's own counts of clicks and scroll depth, no outside account, no recordings |
list_heatmap_pages | orders:read | { from?, to? } → { period, enabled, pages: [{ path, pages, clicks, deadClicks, variants, devices }], summary } |
get_heatmap | orders:read | { path, device?, variant?, from?, to? } → { period, enabled, minPages, heatmap, summary }; relay the summary sentences as they are, nothing is shown under 30 visits |
Experiments inside one store (8) — experiments
| Tool | Scope | Does |
|---|---|---|
list_experiments | orders:read | {} → { experiments: [{ id, name, kind, status, goal, variants, daysRunning, sessionsByVariant, ... }], limits, running } |
get_experiment | orders:read | { id, from?, to? } → { experiment, documents, rules, results? } (results once started: per version sessions, rates, orders, revenue; verdict, split, sessionsNeeded) |
get_experiment_results | orders:read | { id, from?, to? } → the results alone |
upsert_experiment | storefront:write | { id?, name, kind: design | offer, pageScope?, audience?, variants?, goal? } → { experiment }; the first version is the control, weights sum to 100; audience takes devices and countries, source filters are refused |
set_experiment_variant_from_draft | storefront:publish | { id, variantId } copies the current draft into that version (design tests, not the control) |
start_experiment / pause_experiment | storefront:publish | { id }; start refuses with one sentence when a limit, an overlapping running test or a version without a design or offer rule blocks it |
end_experiment | storefront:publish | { id, winner? }; a design winner is written to the draft, never published |
Events and subscriptions (12) — events
| Tool | Scope | Does |
|---|---|---|
describe_events | events:read | The topics seen in this store with counts, the resource × verb vocabulary and aliases, topic patterns, the filter grammar with examples, the delivery envelope; call it before subscribing |
list_events | events:read | { since?, before?, type?, resource?, actor?, limit? } → { events, nextCursor }; actor narrows to one principal (token:<id>, a trailing * matches a prefix) |
get_event | events:read | One event by id |
list_subscriptions | settings:read | Subscriptions with topics, filter, target, status, failure count |
create_subscription | settings:write | { name?, topics, filter?, target: { kind: "webhook", url } | { kind: "agent", url, bearer? }, enabled? } |
update_subscription | settings:write | { id, …changes }; omitted fields and secrets are kept |
delete_subscription | settings:write | { id } |
test_subscription | settings:write | { id } sends a test.ping now → { delivery, event } |
list_deliveries | settings:read | { subscriptionId, limit? } → delivery attempts; status: "dead" lists what used up its retries |
retry_delivery | settings:write | { id } retries a pending, failed or dead delivery with a fresh schedule |
list_webhooks | settings:read | The legacy view of webhook-kind subscriptions with the fixed event list |
upsert_webhook | settings:write | The same compatibility shim for writing one |
Flows (14) — automations
| Tool | Scope | Does |
|---|---|---|
describe_flows | settings:read | The flow shape, the eight actions, the template grammar and helpers, the render context, the store's email templates, recipes, limits and guardrails; call it before creating one |
list_flows | settings:read | Flows with runCount, runs7d, lastStatus, failureCount, disabledReason and readiness; secrets shown as secretSet / bearerSet |
get_flow | settings:read | One flow in full |
create_flow | settings:write | { name, topics, filter?, actions, schedule?, enabled? } — a recipe from describe_flows verbatim works |
update_flow | settings:write | { id, …changes }; omitted fields and secrets kept |
delete_flow | settings:write | { id } |
test_flow | settings:write | { flowId | id, eventId? } dry run: every step rendered against the last matching event or a sample, no side effects |
set_flows_enabled | settings:write | { flowsEnabled }, the store kill switch |
list_flow_runs | events:read | { flowId | id, limit? } → runs with per-step results, message ids and a waiting step's resumeAt |
get_flow_readiness | settings:read | { flowId } → { ready, requirements: [{ id, label, done, detail, href, tool, blocking }] }: what the flow still needs (§18) |
enable_flow | settings:write | { flowId, enabled? } switches it on through that gate; 409 with the readiness while anything blocking is missing |
check_flow_url | settings:write | { flowId, actionIndex? } sends one marked test request to each fixed webhook or agent address and records the answer |
retry_flow_run | settings:write | { runId } re-runs only the steps that failed |
acknowledge_sender_fallback | settings:write | { acknowledged } accepts sending from the shared address until the store's own is verified |
Schedules, inbound hooks and the vault (10) — automations §13–§15
| Tool | Scope | Does |
|---|---|---|
list_flow_schedule_runs | events:read | { id, limit? } → each tick as queued, ok, failed or skipped |
create_flow_hook | settings:write | { flowId, mode: "bearer" | "hmac" } → the inbound URL and the secret, shown once |
list_flow_hooks | settings:read | { hooks: [{ id, flowId, flowName, url, mode, enabled, secretPrefix, lastReceivedAt, receivedCount }] } |
rotate_flow_hook | settings:write | { id } → a new secret, shown once; the old one stops working |
set_flow_hook_enabled | settings:write | { id, enabled } |
delete_flow_hook | settings:write | { id } |
set_secret | settings:write | { name, value }: write-only; the value never comes back |
list_secrets | settings:read | { secrets: [{ name, createdAt, updatedAt, lastUsedAt }], max } |
delete_secret | settings:write | { name } |
describe_secrets | settings:read | Naming rules, limits, exactly where {{ secrets.name }} may be used, what run logs keep, and what promote copies |
Email templates (5) — automations §2
| Tool | Scope | Does |
|---|---|---|
list_email_templates | settings:read | The store's templates, system keys and custom ones |
get_email_template | settings:read | One template with its variables |
upsert_email_template | settings:write | { key, name, subject, bodyHtml, bodyText?, fromName?, replyTo?, variables? }; editing order-shipped or order-confirmation changes the platform's own buyer emails |
preview_email_template | settings:write | { id, eventId? } → rendered subject, HTML, text and errors |
reset_email_template | settings:write | { id } restores a system template's default |
Connector recipes (4) — recipes
| Tool | Scope | Does |
|---|---|---|
list_recipes | settings:read | The library with, for each, what it needs (scopes, secrets, custom fields, options) and what it installs |
get_recipe | settings:read | { id } → one recipe in full, flows and subscriptions included |
install_recipe | settings:write | { id, secrets?, options? }: writes the flows and subscriptions switched off, puts the credentials in the vault, returns a todo list of what is still on the owner |
uninstall_recipe | settings:write | { id } takes them away again |
Discounts, integrations and checkout origins (12)
| Tool | Scope | Does |
|---|---|---|
list_discounts | settings:read | Every code with its kind, conditions, window, limits, combinable, status and both counts |
upsert_discount | settings:write | Creates or edits one code — a named trigger for the same engine as upsert_pricing_rule (checkout-rules §3a): percent, amount, free_shipping or bxgy, the same conditions and applies, a window, store-wide and per-customer limits, combinable, and status active/paused/ended. Sending the same code again edits it. No processor setup: the store applies the code itself |
end_discount | settings:write | { id } ends a code for good; upsert_discount { status: "paused" } pauses it instead |
mint_discount_codes | discounts:issue | { template, count, prefix?, expiresInDays?, email? }: single-use codes from a template (upsert_discount { template: true }), never worth more than it, capped per day. Share one as https://<store>/discount/<CODE>, which applies it |
set_code_limits | settings:write | { perStore?, perCaller?: { function?, flow?, app?, token?, member? } }: the store's own daily limits on minted codes, each 1 to 50,000 (null puts one back to its default). A token or a person, never an app. list_discounts answers the limits in force |
set_gift_exception | settings:write | { subject: "function:<name>" | "rule:<id>" | "code:<id>", productId, allowed }: lets exactly that rule give exactly that product away below its recorded cost. A money decision: only when the merchant asked for a free gift |
list_gift_exceptions | settings:read | Every gift exception and who set it |
list_integrations | settings:read | The registry with per-store status and secretKeys (never values) |
connect_integration | settings:write | { id, status, config?, secrets? }: encrypts secrets and stores the connection |
test_shipping_rates | settings:read | { id, destination: { country, postalCode }, weightGrams?, currency? } |
get_checkout_origins | settings:read | The front-end origins allowed to start checkout from another domain |
set_checkout_origins | settings:write | { origins: string[] } replaces the list (up to 10) |
Custom domains (6) — domains, live store only
| Tool | Scope | Does |
|---|---|---|
list_domains | domains:read | Hostnames with status (pending / active / failed), isPrimary, cnameTarget, maxCustomDomains, configured |
describe_domain_setup | domains:read | { hostname, www?, includeEmail? } → the CNAME records, the detected DNS provider, provider commands with credential placeholders, verification, notes; with includeEmail also the sending-domain records |
add_domain | domains:write | { hostname, www? } registers the hostname (a bare domain brings its www; the pair counts as one) |
check_domain | domains:write | { id } re-reads it now; poll every 30–60 s until status: "active" |
make_primary_domain | domains:write | { id } makes an active domain canonical; every other hostname then 308s to it |
remove_domain | domains:write | { id } |
Plan, billing and walls (6) — billing, live store only
| Tool | Scope | Does |
|---|---|---|
get_plan | settings:read | Plan, entitlements, effective pricing with promotions, usage, activity, the plan catalog, billing, this month's running statement and the referral link |
get_statement | settings:read | { period? } → one month's lines (labels, email overage, extra domains, media overage) and the list of statements |
apply_promo_code | settings:write | { code } → { promotion, pricing } |
describe_walls | settings:read | Every walled feature for this store with used/limit, steps and warnings, before you try. A step that switches a paid feature on carries activateHref |
describe_account | settings:read | The whole account in one answer: plan, payment profile, this month's statement, sending (the address this store's buyers see today and whether campaigns are possible yet), and every feature the merchant can switch on with agentCanDo, askMerchant and activateHref. Call it on any wall or near-full meter |
start_upgrade | settings:write | { plan, interval? } → a hosted Checkout URL the merchant opens and pays on. Nothing is charged by calling it |
Environments (4) — environments
| Tool | Scope | Does |
|---|---|---|
get_environment | settings:read | { live, sandbox, sandboxEnabledAt, selected }; poll after enable_sandbox until sandbox.runtimeStatus is ready |
enable_sandbox | settings:write | Creates the test environment (idempotent) and starts building it; poll until it is ready |
promote_to_live | settings:write | { dryRun? } → per-resource { created, updated, skipped, conflicts }, draftRevision, notes |
refresh_sandbox | settings:write | { dryRun? } → the same summary the other way, published in the sandbox |
Governance (4) — never proposed, never counted; see Governance
| Tool | Scope | Does |
|---|---|---|
describe_governance | any | The rules this token works under: act or propose mode, when it expires, the refund limits, and what pauses it |
list_proposals | any | The writes this token proposed instead of running, newest first, with their status |
get_proposal | any | { id } → the exact arguments stored and, once approved, what running it returned |
withdraw_proposal | any | { id } takes back a pending proposal |
Images
Every image tool (set_product_image, set_hero_image, set_collection_image, upload_image, set_post_image) and the JSON branch of POST /api/product-images take exactly one source: a public https url the platform fetches server-side (redirects re-checked, private and internal hosts refused, 10 s timeout), or dataBase64 plus contentType with the bytes inline (a data:image/…;base64, prefix is tolerated). Either way the platform allows only JPEG, PNG and WebP up to 5 MB, checks the magic bytes against the declared type, and hands the bytes to the store, which keeps them in its own media library. The returned imageUrl is https://<store primary hostname>/media/products/<uuid>.<ext>, served from the store's own hostname with immutable caching; that URL is what the store keeps, so the dashboard and the storefront both render it. The image lives nowhere but the store. Send one image per MCP call. upload_section_video takes inline base64 for one MP4 or WebM up to 50 MB and returns its media URL; it checks the signature and stores the bytes in the selected environment.
Before you act: describe_readiness
Everything that needs setting up before it can work answers one checklist (readiness). describe_readiness { feature? } — and GET /api/readiness?feature=, read-only under settings:read — returns, for storefront_open, checkout, payment_links, discount_codes, campaigns, transactional_email, shipping_labels, live_promotion and custom_code: { feature, label, ready, requirements: [{ id, label, done, detail, href, tool?, severity? }] }. A requirement's href is the dashboard page the merchant finishes it on; its tool is the one an agent calls instead, where an agent can; severity: "recommended" never holds the feature back.
Call it before you act, not after a failure: set_launch_mode (opening the shop), send_payment_link, upsert_discount, buy_shipping_label and promote_to_live all refuse with 409 { error, readiness } carrying this same checklist, so an agent that reads it first never leaves a job half done. Nothing is cached, so a call right after finishing a step shows it finished. A brand-new store is closed until the merchant (or an agent, once storefront_open is ready) opens it, so opening the shop is the last step of a build, not the first.
Set up a whole store
An agent with products:write, settings:write, storefront:write and storefront:publish can take a new store from empty to live:
get_started— the checklist for this store, thenget_storeto confirmruntimeStatus: "ready"and noteprimaryHostname.set_shipping { allowedCountries: ["US","CA"], rates: [{ name: "Standard", amountMinor: 700, currency: "USD", freeOverMinor: 7500, minDays: 3, maxDays: 7 }], origin: { … } }.- For each product:
upsert_product { product: { name, slug, priceMinor, currency, inventory, description, tags, kind?, backorder? } }→ note the returnedid, thenset_product_image { productId, url }or{ productId, dataBase64, contentType }(andslot: "gallery"or{ variantId }for more photos). Adigitalproduct then takesupload_product_file. - Anything the catalog has no box for goes on the object with
set_custom_fields(calldescribe_custom_fieldsfirst). upsert_collection { collection: { name, slug, kind: "manual", productIds } }or a tag rule, thenset_collection_image.set_seo { title, metaDescription, socialTitle, socialDescription }store-wide, and per product or collection with{ ownerType, ownerId }.get_storefront_draft→revision;apply_storefront_commands { revision, commands: [{ type: "set-copy", heading, intro }, { type: "set-theme", presetId }] };set_hero_image;set_navigation;set_content_pages(each call returns the next revision).publish_storefront { revision }.describe_readinessanddescribe_wallsfor what is left with the merchant (payments, a card, a domain), andinstall_recipefor any pattern the store needs from day one.set_launch_mode { access: "open" }whendescribe_readinesssays the shop is ready — a new store starts closed, so nothing sells until this call.
Payments are the merchant's own step. An agent can read whether they are ready (get_started, get_plan) but can never finish them: the merchant finishes payouts in Payments, on a form embedded in the dashboard — no other site, no separate login — or enters their PayPal keys or bank-transfer instructions there. Say that in those words when reporting it; do not send them a processor URL.
The draft → publish loop
get_storefront_draft→ notedraft.revision.list_modulesto discover commands (set-copy,set-colors,set-theme,set-section-content,set-module-settings, …) and module settings schemas.apply_storefront_commands { revision, commands }. The store applies them, gives the draft a new revision and returns it. A stale revision is a 409 →isError; re-read and retry.- The merchant can see the draft in the builder at any time.
publish_storefront { revision }— the only way anything goes live. Requiresstorefront:publish, which the default token scopes do not include.
Storefront appearance and copy are the only thing that waits for a publish. Products, collections, images, custom fields, per-page SEO, redirects, posts, shipping, discounts, pricing and checkout rules, customer groups, integrations, subscriptions, flows, templates and orders all change the store the moment the tool returns.
Version history and restore
Every save and every publish snapshots the whole editor document, and has done since the first editor. list_storefront_versions { limit? } (storefront:read) is the reader: newest first, each entry is { id, at, actor, kind: "save" | "publish" | "restore", revision, sections, pages, changed, summary }, where sections (section instance ids, including one whose section now renders at another version) and pages name what differs from the version before it and changed names which of settings, areas, navigation and appearance moved. actor is the token, the person or the assistant that made it.
restore_storefront_version { id } (storefront:write) writes that snapshot back over the draft with a new revision, exactly as a save would, and records itself in the history as a restore so the restore can itself be undone. It never publishes. What shoppers see is untouched until publish_storefront { revision } runs with the revision it returned, so an agent can restore, read get_storefront_draft, and change its mind. A snapshot comes back as schema version 3 with every section instance and the version it was pinned at; one saved before sections is moved to version 3 on the way, as a stored draft is. A snapshot an older editor wrote that no longer validates is refused with a 409 rather than half-applied. The answer also carries dangling: [{ kind, reference }]: the products, pages, collections and images the old version still points at that are no longer in the store, and any section version it pins that the store no longer keeps (kind: "section"). It never blocks the restore (the merchant asked for that version, and the draft is not live), but it is the list to clear before publish_storefront. Merchants use the same thing under Online Store → Storefront → History, with a confirm that says the change is not live yet.
Retention: the store keeps the newest 50 snapshots of any kind and every publish of the last 90 days; the rest are pruned once a day. Routes: GET /api/storefront-versions and POST /api/storefront-versions { id }.
Media
Every image, video or licensed font an agent or a merchant uploads lands in the store's own media library under products/ — the prefix is a folder, not a promise: the same object may be a product photo, a collection cover, a hero, a video in a section or a picture inside a page. list_media { limit?, cursor?, orphansOnly? } (products:read) lists it with { key, url, bytes, contentType, uploadedAt, inUse, usedBy }, where usedBy names each place that still points at the object (product, variant, collection, storefront, page, post) and inUse: false means nothing does. Media is billed by total size (billing), so orphans cost the merchant money for nothing; orphanBytes in the answer is what cleaning up would free. Upload a video with upload_section_video; a WOFF2 font still needs the owner's licence attestation in the dashboard.
"In use" means the live publication and the draft: the published theme version (which is where the logo and the favicon live, so neither is ever reported as an orphan), the live home page, the draft document, every product, variant, collection, content page and post, and every per-object ogImageUrl — a social card is referenced from nowhere else, so a scan that skipped it called every one an orphan. A superseded publication does not count — otherwise nothing a store had ever shown could be cleaned up.
delete_media { key, force? } (products:write) removes one object. An image something still points at is refused with the list of places that use it, because the alternative is a product page with a hole in it; clear the references first, or pass force: true when you mean it. There is no route for either: this is the media library, and the dashboard does not have one yet.
Element editing and custom blocks
The draft also holds one entry per element — a single heading, paragraph, button or image inside a section — so an agent is never limited to whole sections:
set-element { areaId, key, value?, href?, src?, alt? }writes one element's text, its button destination or its image.areaIdis a home section (hero,collection,more-products,styles) or a page area (chrome,catalog,product,cart, …);keyis one the theme declares.list_modulesreturns the command schema, andget_storefront_draftshows what is set today.set-element-style { areaId | instanceId, key, align?, size?, visible?, spacing?, order?, offsetX?, offsetY?, mobile? }changes its look and place. On a store section it takesinstanceIdand one of the elements its template marks (data-fh-el);order(-20 to 20) moves it inside its group,offsetXandoffsetY(-400 to 400 px) nudge it, andmobile { order?, offsetX?, offsetY?, align?, visible? }applies at phone width only. The rest works on both kinds of section:alignisleft | center | right,sizeiss | m | l | xlon the theme's own type scale,spacingis0–4, andvisible: falsetakes the element off the page. Passnullforalign,sizeorspacingto hand it back to the theme. Nothing here is CSS: the values map onto each theme's own tokens, so the page stays responsive and looks like the theme.set-element-order { instanceId, group, order: [keys] , mobile? }numbers the elements of one reorderable group (data-fh-group) of a store section in one step. A store section's text, images and links are its settings:update-section { instanceId, settings }. See sections, "Editable elements".remove-section { instanceId }takes a theme home section (hero,collection,more-products,styles) off the page for good, not just hidden;add-section { sectionId: "hero", position? }puts it back once, on home.add-custom-block { html?, css?, position?, placement?, sectionId?, pageSlug?, width?, visible? }puts the merchant's own markup anywhere on the storefront (see "Page layout" below), withupdate-custom-block,move-custom-blockandremove-custom-block { id }after it. Up to 12 blocks per page, 20 KB of HTML and 8 KB of CSS each. The HTML is reduced to a safe subset (headings, paragraphs, lists, links, images, tables, and YouTube or Vimeo embeds) and the CSS is confined to that block; scripts, event handlers, inline styles,javascript:links and non-HTTPS URLs are removed, not escaped, so read the draft back to see what was kept. See Storefront modules v2. Nothing inside a block can be selected or edited in the builder; for anything the merchant should change later, build a section.
Every one of these is an ordinary editor command: same revision check, same draft, live only after publish_storefront.
Page layout
Every page is one ordered list of sections and custom blocks, in the order shoppers see it. The draft shows it as document.layout:
{
"pages": {
"home": [
{ "kind": "block", "id": "intro" },
{ "kind": "section", "id": "hero" },
{ "kind": "section", "id": "collection" },
{ "kind": "block", "id": "faq" },
{ "kind": "section", "id": "more-products" },
{ "kind": "section", "id": "styles" }
],
"catalog": [{ "kind": "block", "id": "sale" }, { "kind": "section", "id": "main" }],
"page:about": [{ "kind": "section", "id": "main" }, { "kind": "block", "id": "team" }]
},
"slots": { "header.top": [{ "kind": "block", "id": "notice" }] }
}
home lists its four sections exactly once each. catalog, product and page:<content page slug> have one section, main, which is the page's own body; a page with no blocks is simply left out. slots["header.top"] sits under the announcement bar and above the navigation, slots["footer.top"] just above the footer, on every page but the payment page, and they hold blocks only. A hidden section draws nothing; the blocks beside it stay where they are.
Where a block goes:
position { placement?, target, page?, slot? }onadd-custom-block,move-custom-blockandupdate-custom-block.placementisbeforeorafter(defaultafter);targetis a section id, a block id,startorend;pageishome(default),catalog,productorpage:<slug>;slotisheader.toporfooter.top. A block target brings its own page. Example:{ "type": "move-custom-block", "id": "faq", "position": { "placement": "before", "target": "hero" } }, or{ "type": "add-custom-block", "html": "<p>Free delivery this week</p>", "position": { "target": "start", "slot": "header.top" } }.- The older
sectionId(withplacement) still works and means right after that home section and after the blocks already there, so blocks added one by one come out in the order they were sent;pageSlugmeans above or below that content page's body. Send eitherpositionor these, not both. set-layout { page, order }(or{ slot, order }) sets a whole page at once:orderlists every section of that page once and every block that should be on it, as{ kind, id }or a bare id (a bare id is a section when the page has one by that name). A block from another page or slot moves in; a block already on the page and left out is refused rather than lost. Example:{ "type": "set-layout", "page": "home", "order": ["styles", { "kind": "block", "id": "intro" }, "hero", "collection", "more-products"] }.move-section { sectionId, index }moves a home section alone; the blocks keep their places.
A target that is not there is refused with the usual command error, for instance Command 2 (move-custom-block, faq): position.target "nope" is not a section or a block on the storefront. Drafts written before layouts existed are read with the layout their blocks' anchors describe, which renders exactly as before.
Starting from a theme
If the merchant wants a ready-made look, start from a theme before designing from scratch (themes): list_themes (each with pictures of its home page and current on the one the draft wears), get_theme { id } for one whole, then apply_storefront_commands { revision, commands: [{ type: "set-theme", presetId: "<id>" }] }. The six original themes put their own header, footer and home page in the store and keep the merchant's colours, typefaces, copy and menus; every other theme brings its whole look. The merchant's own sections are kept, hidden at the end of their page, and the answer's theme.summary names what changed. Then adjust with apply_storefront_commands and publish. The draft as it was is in the version history as "Before preset <name>". apply_preset { id, dryRun: true } says what a theme would change before anything is written, and apply_preset { id, reset: true } puts a theme's original sections back over the store's own copies as new versions; list_presets, get_preset and apply_preset are the older names for the same catalogue.
Sections of your own
When a layout repeats, or the merchant should edit it later, build a section rather than a raw HTML block: a package of typed settings the builder turns into fields, a template, its own styles and presets, saved once and placed as often as needed (sections). The loop:
describe_sectionsfor the package format, the setting types, the template language and the limits.save_section { package }. The package is checked whole, and a refusal names every problem (Section promo-band, template line 4: …). Saving again makes a new version; leaveversionout and it is chosen for you.preview_section { id, settings }to see one instance before it is on any page.apply_storefront_commandswith{ type: "add-section", sectionId, position, settings }places it on the draft;update-section,apply-section-preset,duplicate-section,set-section-visibilityandremove-sectionchange it later.get_preview_link, thenpublish_storefront { revision }.
Saving a section moves the draft to its new version; the live storefront keeps the version it was published with until you publish. Add-on and catalogue sections are read-only: fork_section makes your own copy.
Orders and fulfilment
POST /api/order-actions is the single route behind every button on an order, and it answers a browser session and a bearer token the same way: the same body, the same JSON back, the same entry in the event log (with the actor token:<id> when an agent did it). Body: { storeId?, orderId, action, … }.
| Action | Scope | Extra fields | Does |
|---|---|---|---|
fulfill | orders:write | carrier?, trackingNumber?, trackingUrl?, notifyCustomer?, lines? | Marks the order fulfilled, registers tracking and, with notifyCustomer, sends the store's order-shipped email. With lines it ships part of the order |
refund | orders:write and orders:refund | amountMinor?, note?, restock? | Refunds through the processor that took the payment; without amountMinor the whole unrefunded remainder. restock: true returns the goods to stock (off by default). Agents also meet the refund limit |
cancel | orders:write | — | Cancels an unfulfilled order, returns stock (a paid order restocks from its own lines) and closes an open checkout. Refused once any line has shipped |
note | orders:write | note | Replaces the internal note (the buyer never sees it); an empty string clears it |
tags | orders:write | tags | Replaces the order's tags; an empty list clears them |
track / set-tracking / mark-paid | orders:write | carrier?, trackingNumber?, trackingUrl?, notifyCustomer? | Refreshes tracking, records a number you shipped yourself, or records payment on a bank-transfer order |
send-payment-link | orders:write | notifyCustomer? | Opens a card checkout for an order you created yourself and emails the customer the link (see below) |
The MCP tools fulfill_order, refund_order, cancel_order, add_order_note and tag_order call this route, so the rules below apply identically.
Orders you create yourself
Not every sale starts on the storefront. A phone order, a counter sale or an invoice is created with POST /api/orders (orders:write, or the create_order tool):
{ "customer": { "email": "buyer@example.com", "name": "Ada", "phone": "+1555" },
"lines": [{ "productSlug": "granola", "variantId": null, "quantity": 2 }],
"shipping": { "amountMinor": 500, "label": "Courier" },
"discountMinor": 200, "note": "Ordered over the phone.",
"shippingAddress": { "line1": "1 Loop Road", "city": "Portland", "state": "OR", "postalCode": "97201", "country": "US" } }
The store prices every line from its published catalog and reserves the same stock a shopper's checkout reserves, so the order is real inventory, not a note; a product with options must name a variantId, an unpublished slug is a 404 and not enough stock is a 409, in both cases before anything is held. Up to 20 lines and 10 of each. The answer is { order }: pending, source: "manual", with the next order number and totalMinor = subtotal + shipping − discount.
A line may also carry unitPriceMinor — the price the merchant agreed, instead of the catalog's. A token cannot: that field from a bearer token is refused with 403 Only a signed-in merchant can set a price of their own on an order line., so an agent can only ever sell at the published price.
Then collect the money, two ways:
- **
POST /api/order-actions { action: "send-payment-link" }** (orsend_payment_link) opens a card checkout for that order and emails the customer the store's Payment request template, which carriesdata.paymentUrl. The answer is{ order, paymentUrl, expiresAt, emailSent, emailError };notifyCustomer: falsereturns the link without sending it. The order is re-keyed to the new checkout, which is what the payment webhook settles by, so paying the link marks that order paid — no second order and no second reservation — and sending a new link expires the previous one. The store needs card payments set up and the order must still be awaiting payment; anything else is a 409. The link may be emailed at most 3 times a day for one order and 10 times a day to one customer; past that the answer still carriespaymentUrlwithemailSent: falseand anemailErrorsaying so, so the merchant can pass the link on themselves. - **
{ action: "mark-paid" }** records cash or a transfer that already arrived, exactly as it does for a bank-transfer order.
Either way the order then behaves like any other: fulfil it, refund it, cancel it, export it. In the sandbox the payment link is a test-mode checkout on fake money. Merchants do the same from Orders → Create order.
Partial fulfilment
Ship what you have: fulfill with lines: [{ lineId, quantity }], where lineId is items[].id from GET /api/orders / get_order. Quantities are cumulative across calls and can never exceed what was ordered. While anything is still open the order reads fulfillmentStatus: "partially_fulfilled" and the result carries fulfillment: { status, complete, lines: [{ lineId, productName, quantity, fulfilledQuantity }] }; the call that ships the last line marks the order fulfilled exactly as a whole-order fulfilment does. A partly shipped order can no longer be cancelled — refund it instead.
Carrier, tracking and the buyer's shipping email belong to the whole order (one tracking number per order), so send them with the call that ships the last line, or use set-tracking afterwards; a partial call that carries them is refused with a 400 saying so. Since 2026-09-22 the per-line quantities are kept with the order's own lines, so get_order, list_orders and the dashboard all report partially_fulfilled after a reload, and every line carries fulfilledQuantity. The value the storefront and the CSV export read stays unfulfilled until the last line ships, so both are unchanged.
Refund limits for agents
Refunds are the one order action that moves money out, so besides the orders:refund scope there is a ceiling the owner sets in Settings → Agents & API → Guardrails (#settings/agents/guardrails): the most one refund may be, and the most all of a store's tokens may refund in a day (UTC, counted across every token and per environment). The defaults are 500.00 per refund and 2,000.00 per day in the order's currency; 0 stops agents refunding at all. A merchant refunding in the dashboard has no limit and never uses up the agents' allowance.
Over either limit the refund is refused with 409 and the usual wall body, so an agent can hand the merchant the link instead of retrying or splitting the refund into smaller ones:
{ "error": "An agent can refund at most 500.00 USD on one order. …",
"wall": true, "feature": "refunds", "reason": "limit",
"steps": [{ "id": "dashboard", "label": "Refund this order from the dashboard", "done": false, "href": "/dashboard#orders/ord_…" },
{ "id": "limit", "label": "Raise the agent refund limit", "done": false, "href": "/dashboard#settings/agents/guardrails" }],
"usage": { "used": 75000, "limit": 50000 } }
GET /api/agent-limits (session only) returns { limits, defaults, refundedTodayMinor } and PUT writes both numbers; null restores a default. In the sandbox the same limits apply to fake money.
Bookkeeping exports (CSV)
export_orders { from?, to?, status? } (orders:read) hands the same file back as text. The date and status filter runs before anything else is read, and one call carries at most 500 orders and 5 MB; the answer says matched (how many there were) beside orders (how many went out), so truncated: true is a fact rather than a guess and the fix is to narrow from/to. export_products (products:read) and export_analytics { period } (orders:read) do the same for the catalog and for a period's numbers. For a file bigger than one tool result, use the route directly with a bearer token:
curl -sS -H "Authorization: Bearer fh_<mode>_…" \
"https://formahand.com/api/orders?format=csv&status=paid&from=2026-09-01&to=2026-09-30" \
-o orders-september.csv
One row per order item (order totals repeat on every line of the same order); money is a decimal string in major units (47.50; 3000 for a zero-decimal currency such as JPY). status is all (default), paid (paid, including later refunded), pending, refunded, unfulfilled, fulfilled or cancelled; from/to are inclusive UTC days (YYYY-MM-DD) on the order's creation time. The response is text/csv; charset=utf-8 with a UTF-8 BOM, CRLF line endings, Content-Disposition: attachment; filename="orders-<store slug>-<YYYYMMDD>.csv" and Cache-Control: no-store. Cells that would start a spreadsheet formula (=, +, -, @) are prefixed with an apostrophe. Up to 5000 most recent orders per export; narrow with from/to beyond that.
Columns (order_tax_rate carries a single rate and is blank where several jurisdictions apply; order_tax_jurisdictions names them all; customer_tax_id is the VAT number the buyer gave, when they gave one — payments §6c): order_number, order_id, created_at, paid_at, status, fulfillment_status, customer_email, customer_name, customer_phone, currency, item_name, item_sku, item_variant, item_quantity, item_unit_price, order_subtotal, order_shipping, order_discount, order_discount_code, order_tax, order_tax_rate, order_tax_jurisdictions, customer_tax_id, order_total, order_refunded, shipping_method, ship_to_name, ship_to_line1, ship_to_line2, ship_to_city, ship_to_state, ship_to_postal_code, ship_to_country, carrier, tracking_number, payment_session_id, payment_reference, came_from_channel, came_from_source, came_from_campaign, came_from_link. payment_session_id and payment_reference are the ids the payment carries: the checkout id the order was reserved under, and the payment reference that settled it. The came_from_ columns say where the order came from (order-sources) and are blank when it was not recorded.
The catalog export has no route of its own: export_products is the surface, and its columns are handle, name, status, kind, variant_title, sku, barcode, price, compare_at_price, cost, currency, inventory, weight_grams, vendor, product_type, tags, image_url, image_alt, country_of_origin, hs_code, description — one row per sellable thing (a product with variants gets a row per variant carrying the product's own fields), which is the importer's own shape, so an edited export goes straight back through import_products. The analytics export is one file holding three tables told apart by a section column: series (one row a day), best_seller and referrer.
The customer export (GET /api/crm?format=csv: email, name, phone, marketing_status, tags, total_orders, total_spent, currency, created_at, updated_at) takes a token with both customers:read and customers:export; merchants download the same file from Customers → Export CSV. The export_customers tool returns the same CSV as text. Contacts, orders and products all come back from the store as a page with a nextCursor, so every export follows that cursor to the end of the list rather than mistaking the first page for all of it; it cuts only on the 5 MB cap, only between whole rows, and reports rows, total and truncated so a partial file can never look complete. The CSV routes say the same thing in their headers, because a download cannot carry a field: X-Formahand-Rows and X-Formahand-Truncated. Neither export is the same thing as the whole-store export, which is owner-only.
Gift cards
A gift card is money somebody already paid, waiting to be spent (payments § Gift cards). **Reading the till is order data; issuing credit is money**, and the surface draws that line and no other.
| Tool | Scope | Does |
|---|---|---|
list_gift_cards | orders:read | Every card: what it is worth now, what it was issued for, who issued it, and what is outstanding in total. |
get_gift_card | orders:read | One card with its whole history — issued, held at a checkout, spent, given back, refunded onto, adjusted, stopped. This is where "why is this card worth what it is worth" is answered. |
issue_gift_card | gift_cards:issue | Puts new credit in the till. Pass deliver and the store emails the code to whoever it is for, in the merchant's own words from their own address. |
void_gift_card | gift_cards:issue | Stops a card. Its balance stays on the record so the books add up; stopping moves no money. |
adjust_gift_card | gift_cards:issue | Moves a balance by hand. Adding is issuing, and counts the same way; taking off never goes below zero. |
Three things to hold on to:
The code comes back once. issue_gift_card answers it, and nothing afterwards can read it back — the store keeps a one-way value. Hand it on in the same breath, or pass deliver and let the store send it. An agent that loses it voids the card and issues another.
It is idempotent per sale. orderId + orderLineId means a retry whose answer you never saw issues one card, not two. Always pass both when a sale paid for the card.
You are held to the owner's money limits. The same two numbers a refund obeys, counted separately. Over one you get a sentence and a dashboard link, not a card — hand the merchant the link rather than retrying.
Returns
A buyer opens a return on the store's own order page; everything after that is agent work. POST /api/return-actions is the one route behind every move, and it answers a session and a bearer token the same way. Body: { storeId?, returnId, action, … }.
| Action | Scope | Extra fields | Does |
|---|---|---|---|
approve | orders:write | label? (instructions, labelUrl, carrier, trackingNumber), merchantNote?, notifyCustomer? | Approves a requested return and emails the buyer the store's return-approved template |
decline | orders:write | merchantNote (required; the buyer reads it), notifyCustomer? | Declines it and emails the buyer |
receive | orders:write | lines: [{ lineId, quantity, condition, restock }] | Records what actually came back, line by line |
refund | orders:write and orders:refund | amountMinor?, merchantNote?, notifyCustomer? | Refunds through the order's own processor; without amountMinor the returned goods' value. The same cap and the same agent money limit as refund_order |
restock | orders:write | — | Puts the received units of the ticked lines back on the shelf, once per return |
close | orders:write | merchantNote? | Ends the return. Final |
The tools list_returns, get_return, approve_return, decline_return, mark_return_received, refund_return, restock_return and close_return call this route, so the rules are identical; set_return_policy writes the window, the policy and the instructions (settings:write).
What an agent should check first. get_return carries status, linesMinor (what the returned goods are worth) and refundableMinor (what the order still has left). A refund can never exceed refundableMinor, and a move the state machine forbids comes back as `A <status> return cannot become <status>.` rather than a silent no-op — read the status, do not retry.
Return labels are not bought here. No carrier module sells one, so approve_return takes a labelUrl the merchant already has; without it the buyer gets instructions (the store's own, from set_return_policy, when you pass none).
Full behaviour: orders "Returns and refunds".
Editing an order
A buyer who wants one more of something, or a different address, does not need the order cancelled and re-made. POST /api/order-edits (orders:write) is the one route behind every move, and it answers a session and a bearer token the same way. Body: { storeId?, action, orderId? | editId?, … }.
| Action | Tool | Does |
|---|---|---|
begin | begin_order_edit | Opens a draft holding the order exactly as it is. An order that may not be edited is refused with the reason |
set | edit_order_draft | Sets lines, address, shipping method, discount, contact details and the reason. Only the fields you send are touched |
preview | preview_order_edit | Prices the draft and answers { before, after, diff, deltaMinor, consequence, problems }. Changes nothing |
apply | apply_order_edit | Writes the edit and settles the money |
cancel | cancel_order_edit | Throws the draft away; the order was never touched |
waive | waive_order_edit_balance | Closes an unpaid difference — forgiven, or collected another way |
list | list_order_edits | The audit trail: who, before, after, the difference, how it settled |
Always preview before you apply. consequence.sentence is the one line a merchant would read — "Buyer owes 12.40 USD. We'll email a payment link", or "Refund 8.00 USD to the buyer's card" — and problems lists everything that would refuse the edit (stock, an address the store does not ship to, a discount code that does not apply). An edit that costs a customer more money is not a thing to discover afterwards.
What an agent may not do.
- Set a price of its own on a line.
edit_order_draftrefusesunitPriceMinorfrom a token with 403, exactly ascreate_orderdoes: an agent sells at the catalog price the merchant published. - Move more than the owner's money limit. The single-refund limit (Settings → Agents & API) governs an edit in both directions, because an agent that could raise an order without limit could empty a customer's card as easily as it could empty the merchant's. Over it,
apply_order_editis refused with{ wall: true, feature: "order_edits", reason: "limit", steps }— hand that to the merchant rather than splitting the edit into smaller ones. - Edit an order that is unpaid, cancelled, refunded, fully shipped, or already waiting on an earlier edit's balance.
get_ordercarriesedits.editableandedits.reason, so an agent can tell before it starts.
lines on edit_order_draft is the complete list the order should end up with, like tag_order's tags: read the draft first, send every line you want kept. A line that has already shipped cannot be removed or taken below what shipped.
Full behaviour: orders §6 "Editing an order".
Customers and marketing
Customers and campaigns are agent work, under the same walls the dashboard meets — there is nothing session-only about them.
GET /api/crm (customers:read) lists the store's contacts and takes q (email or name), tag, minOrders, minSpentMinor, marketingStatus (subscribed | unsubscribed | pending), limit (default 50, max 250) and cursor; a request with none of those returns the plain { customers } body the dashboard has always read, and one with any of them adds nextCursor and total. POST /api/crm (customers:write) upserts one contact by email. GET /api/crm?format=csv needs customers:export as well and answers 403 This token needs the customers:export scope. without it. Tagging merges into the contact's existing tags and leaves consent, name and notes alone.
Campaigns run as draft → preview → send. create_campaign writes a draft with a subject, a body (bodyHtml or bodyMarkdown) and an audience of subscribed contacts, optionally narrowed to one tag; nothing leaves the building. preview_campaign { id } returns the draft, the audience, the recipients of the next send and the marketing wall as it stands, so an agent can tell the merchant what is missing before it tries. send_campaign { id } sends, and meets exactly the wall the dashboard's Send button meets (email-marketing): a plan with marketing, a live custom domain, a verified sender on that domain, the current terms accepted, inside the monthly allowance and today's warm-up cap, and no reputation pause. The refusal is the same 402/409 wall body with steps, and POST /api/campaigns behaves identically for a bearer token and for a session. There is no scheduler yet, so scheduleAt is refused rather than silently dropped.
One customer's page
GET /api/crm?id=<customer id> (customers:read) and get_customer return the same object: customer (the contact, plus customerGroup, country, firstOrderAt, lastOrderAt, accountStatus, lastSeenAt), orders (the last 20: number, date, totalMinor, currency, status), timeline (the customer's creation, each order, each marketing and tag change as the event log recorded it, and each note — newest first), addresses (the shopper's saved address first, then every address a parcel went to, deduplicated), stats (orders, totalSpentMinor, averageOrderMinor, firstOrderAt, lastOrderAt, daysSinceLastOrder), notes, tags, customerGroup and accountStatus. Notes are the only part written by hand: POST /api/crm/notes { customerId, note } (customers:write) or add_customer_note. They are never edited or deleted, so the page reads as a record of what happened.
Segments
A segment is a saved filter over the contact list — never a copy of it, so it is always current. The grammar is the one GET /api/crm and list_customers take: q, tag, minOrders, maxOrders, minSpentMinor, marketingStatus, acceptsMarketing, lastOrderWithinDays, lastOrderOlderThanDays, firstOrderWithinDays, customerGroup and country. GET /api/segments lists the store's segments (list_segments), PUT /api/segments { id?, name, filter } saves one (upsert_segment), DELETE /api/segments?id= removes it (delete_segment; the contacts stay), preview_segment { filter } counts a filter before it is saved, and GET /api/crm?segment=<id> lists who is in one today.
Four segments exist on every store without being saved and cannot be edited or deleted: Repeat customers (repeat-customers), Bought once, 60+ days ago (lapsed-one-order), Subscribed, never ordered (subscribed-never-ordered) and Top spenders (top 10%) (top-spenders, ranked against the store's own list rather than a fixed amount). A campaign can be aimed at any of them: create_campaign { audience: { segmentId } }, or POST /api/campaigns { audience: { segmentId | tag | email } } — and consent is applied after the narrowing, so a segment can only ever reach its subscribed members.
What a token still cannot do here: subscribe someone (consent comes from the storefront, checkout or the merchant, never from a tool), verify the sender or accept the terms (both session-only merchant steps), send to anyone outside the subscribed audience, send more than 50 addresses per call, or get past the caps and the pause. Tags and segments are the audience grammar, and customer groups (checkout-rules) are how an agent prices one.
Automation: events, flows, schedules, hooks, recipes
Events and subscriptions
Every write to a store (orders, products, customers, settings, publication, integrations without their secrets) is recorded in the store's own event log as <resource>.<verb> plus semantic aliases such as order.paid and order.delivered. A subscription names topic patterns (order.*, *.created, *), an optional predicate and a target (a webhook URL, or an agent endpoint that receives a CloudEvents batch), and deliveries are signed per Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature) plus the legacy X-Formahand-Signature, with retries at 1 min, 5 min, 30 min, 2 h and 8 h. The full model, grammar, envelope and verification snippet are in events.
describe_events
create_subscription {
topics: ["order.updated"],
filter: 'changed contains "trackingStatus" and data.trackingStatus == "delivered"',
target: { kind: "webhook", url: "https://hooks.example.com/formahand" }
}
test_subscription { id }
Routes: GET /api/events, /api/events/:id, /api/events/vocabulary (events:read); GET/POST/PUT/DELETE /api/subscriptions, POST /api/subscriptions/:id/test, GET /api/subscriptions/:id/deliveries, POST /api/deliveries/:id/retry, GET /api/events/echo (settings:*). /api/webhooks (GET / PUT { id?, url, events, status } / DELETE ?id=) stays as a compatibility shim over webhook-kind subscriptions with the legacy events order.paid, order.fulfilled, order.cancelled, order.refunded, product.updated (the last one maps to product.created + product.updated). Never add a bespoke webhook event again: a new need is a new topic on the existing log.
Dead letters: GET /api/deliveries?status=dead&limit= (settings:read) lists the deliveries and flow runs that used up their retries; retry_delivery { id } puts one back.
Flows
Flows react to the same events inside the store, without a receiver (automations): topics and a predicate pick the events, an ordered list of actions does the work (send_email with a store template, notify_merchant, add_order_note, tag_order, tag_customer, webhook, wake_agent, set_order_status), every string field is a template over the event, and runs, retries and auto-disable reuse the delivery engine. Email templates are per store; the eight system keys are seeded and editable, and the platform's own confirmation and shipping emails render through order-confirmation and order-shipped. Guardrails: one run per (flow, event), 200 emails per store and 5 per recipient per hour, loop protection through the actor system:flow:<id>:<depth> (three hops), a dry run, a kill switch.
A step may also be { type: "wait", minutes }, which holds the run and lets the steps after it carry on later — durably, in the run's own row, with an order re-read when it resumes. A flow cannot be switched on until what it needs exists: get_flow_readiness lists it and enable_flow refuses with the same list, so no automation is ever enabled to fail.
describe_flows
create_flow {
name: "Unpaid after an hour → reminder",
enabled: false,
topics: ["order.created"],
filter: 'data.status == "pending" and data.customerEmail != null',
actions: [
{ type: "wait", minutes: 60 },
{ type: "send_email", template: "payment-instructions", to: "buyer", when: 'data.status == "pending"' }
]
}
test_flow { flowId } -> every step rendered, nothing sent
get_flow_readiness { flowId } -> what is still missing, each with its tool
enable_flow { flowId } -> 409 with the same list until it is ready
list_flow_runs { flowId } -> the runs, step by step; retry_flow_run { runId } re-runs only what failed
Routes: /api/flows, /api/flows/:id, /api/flows/settings, /api/flows/recipes, /api/flows/describe, POST /api/flows/:id/test, POST /api/flows/:id/readiness (settings:read), POST /api/flows/:id/enable, POST /api/flows/:id/check-url (settings:*), GET /api/flows/:id/runs and GET /api/flows/:id/schedule-runs (events:read), /api/email-templates, /api/email-templates/:id, POST /api/email-templates/:id/preview, POST /api/email-templates/:id/reset (settings:*).
Schedules, inbound hooks and the vault
A store can also act on its own: poll on a clock, be called by someone else, and sign in to someone else's system — with no server of the merchant's in between (automations §13–§15).
On a clock. create_flow / update_flow take schedule: { every: "15m" | "1h" | "6h" | "1d", at?: "HH:MM", timezone?: <IANA> }; the topic schedule.tick is added for you and the run carries data.flowId, data.scheduledFor and data.timezone. list_flow_schedule_runs { id } shows each tick as queued, ok, failed or skipped. Three failed ticks in a row pause the schedule and email the merchant; the flow keeps running on events, and sending the schedule again resumes it. Budgets: 3 schedules and 200 runs a day on Free, 20 and 2,000 on Growth, 50 and 10,000 on Pro.
Called from outside. create_flow_hook { flowId, mode: "bearer" | "hmac" } returns https://formahand.com/hooks/<storeId>/<flowId> and the secret once; list_flow_hooks, rotate_flow_hook { id }, set_flow_hook_enabled { id, enabled } and delete_flow_hook { id } manage it (settings:*, reads with settings:read). Callers send Authorization: Bearer <secret> or X-Formahand-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>"> within 5 minutes. POST only, JSON up to 64 KB, 60 a minute; answers 202 { ok, runId }, 401, 404, 413, 429. Sandbox hooks arrive switched off.
The vault. set_secret { name, value }, list_secrets, delete_secret { name }, describe_secrets, or PUT/GET/DELETE /api/secrets. Write-only: names and timestamps come back, values never. Use one as {{ secrets.<name> }} in a webhook action's url or headers or a wake_agent bearer — anywhere else the flow is refused when it is saved. Run logs show ***. Each environment has its own vault; promoting and refreshing never copy secrets.
Every URL the store itself will call (subscription targets, webhook and wake_agent) must pass the outbound policy: public https only, no credentials in the URL, no private, loopback or link-local address, no internal name, and never one of our own domains (events §12).
Connector recipes
A recipe is a whole integration — flows, subscriptions, the credentials it needs, the custom fields it uses — installed switched off, with a todo list of what is still on the owner. Six ship today: a dropship supplier, print on demand, a nightly sales report, low-stock reordering, chat order alerts and a spreadsheet sync. list_recipes, get_recipe { id }, install_recipe { id, secrets?, options? }, uninstall_recipe { id }, or GET /api/recipes / POST /api/recipes/install (settings:read / settings:write). Reach for one before hand-building a pattern: it is the fastest correct answer and the owner still has to enable it. See recipes.
Data an agent can shape
Custom fields
Anything Formahand never designed a box for goes on the object itself as a typed, namespaced custom field (custom-fields): a supplier order id on a product, a cost price on a variant, a print file link on an order line, a wholesale tier on a customer. describe_custom_fields explains the six owner types (product, variant, collection, order, order_line, customer), the seven value types (text, number, money, date, boolean, json, file) and the limits (50 fields per object, 20,000 per store, 8 KB of JSON); set_custom_fields merges a list of { namespace, key, type, value } onto one object and delete_custom_field removes one. Products, orders and customers read back with a fields array when they have any, every write records a custom_field.created / .updated / .deleted event, and promote and refresh copy the fields of products, variants and collections. A checkout rule can read one (customField { ownerType, namespace, key, equals }) and a discount will never take a line below its supplier.cost field when the store keeps one. Values are never rendered on the storefront.
Product details
Everything a merchant expects to type about a product has its own field, so nothing has to be squeezed into a custom field. upsert_product and PUT /api/products take them, list_products / get_product return them, and a product written without them keeps the defaults.
| Field | Shape | Public |
|---|---|---|
vendor | ≤ 80 characters — who makes it | yes |
productType | ≤ 80 characters — what it is | yes |
barcode | ≤ 64 characters, on the product and on each variant. A barcode of digits only must be a real GTIN (8, 12, 13 or 14 digits, check digit included); anything else is taken as the merchant's own code | yes |
costMinor | integer minor units, or null, on the product and on each variant — what one unit costs the merchant, and the margin the editor shows next to the price | never: it is left out of the storefront read API entirely |
dimensions | { lengthCm, widthCm, heightCm } or null, on the product and on each variant; each side 0–300 cm. All three or none | yes |
countryOfOrigin | ISO 3166-1 alpha-2, stored upper case | yes |
hsCode | 6 to 10 digits (dots and spaces are dropped) | yes |
taxCode | the product's tax category, e.g. txcd_99999999; "" uses the store's default (payments) | no |
unitPrice | { amountMinor, unit } or null, unit one of kg, 100g, l, 100ml, m, m2, piece. Shown under the price on the storefront | yes |
countryOfOrigin and hsCode are the two facts a customs declaration asks for. Formahand Shipping quotes and buys labels from the ship-from address, the destination and the parcel (quoteFormahandRates → one parcel body per shipment); it has no customs section yet, so these two fields are stored and read back but not yet sent. When the label request grows a customs block, each order line's product supplies its own countryOfOrigin, hsCode, productName and unitPriceMinor, and the store's origin country supplies the rest. The same holds for dimensions: a label is quoted with the store's default box today, and the per-product size is the value the parcel body will read once an order line carries it.
Descriptions accept a small rich text: p, h2, h3, strong, em, u, s, ul, ol, li, a, img, blockquote and nothing else. Anything else is dropped (its text is kept), every attribute but href, src and alt is dropped, links must be https, mailto or site-relative and images must be https — the store sanitizes on save, so the markup that comes back is the markup that renders. A description that is plain text (as most agents send) is stored and returned exactly as it was typed. The public read API carries both: description is the text form, descriptionHtml the sanitized markup.
Product kinds and downloads
A product is a box, a file or an hour of work (product-kinds). upsert_product takes kind (physical — the default — digital or service) and backorder ({ allowed, promisedAt?, message? }); list_products / get_product return both, and a digital product also returns files: [{ id, name, bytes, contentType }] (never where the file itself is kept). The routes behind the file tools are GET/POST/DELETE /api/product-files (products:read / products:write; multipart file, or JSON { productId, url } / { productId, dataBase64, contentType }) and POST /api/product-files?orderId= to re-issue links (a token also needs orders:write).
Checkout follows the kinds: a cart of digital and service lines only is created without shipping collection and without rates, a mixed cart collects both as before, and a product with backorder.allowed can be bought at zero stock — its order line comes back with backordered: true and its promise appears on the product page, the cart and the confirmation email (downloads and the backorder sentence are part of the email render context, so a flow's send_email can use them). Download links are signed per (order, file) with a per-store secret and last 72 hours; they are served from the store's own hostname at /downloads/<token> and never point at the platform.
Content and SEO
Three things an agent can change that decide how a store is found, and how much of it there is to find (content-seo).
Per-page SEO. set_seo with ownerType (product, collection, page or post) and ownerId — the object's public handle, not an internal id — saves an override for one page: title (≤ 60), metaDescription (≤ 160), canonical, ogImageUrl and noindex. An empty field keeps the tag the store generates, and an override whose fields are all empty is removed. Without ownerType the tool is the store-wide setting it has always been. get_seo takes the same pair; list_seo_overrides lists every override. Routes: GET/PUT/DELETE /api/seo?ownerType=&ownerId= and GET /api/seo?list=1. get_seo_audit lists what to fix first (titles, descriptions, headings, images, broken links, redirects, whether the store is open to search); route GET /api/seo-audit.
Redirects. Changing a product slug, a collection handle, a content page slug or a post slug writes a 301 from the old address and carries that object's SEO override across — an agent that renames things never leaves dead links behind. set_redirect { fromPath, toPath, status? } adds one by hand (a store path, or an https URL to send people off-site), list_redirects shows them with how often each was followed, and delete_redirect { id | fromPath } removes one. A redirect that would send a shopper back where they started is refused, trailing slashes and query strings never distinguish two rows, and a store keeps up to 500. The storefront checks redirects before anything else it serves. Routes: GET/PUT/DELETE /api/redirects.
Posts. upsert_post writes the store's own writing in the same Markdown content pages use; set_post_image attaches a cover from the store's media library; list_posts and delete_post do the rest. Only a published post renders, and publishing stamps publishedAt once. Published posts appear at /blog (12 a page), /blog/<slug> with Article structured data, /blog/feed.xml as RSS 2.0, and in /sitemap.xml; a front end of your own reads them from the storefront API (headless). Routes: GET/PUT/DELETE /api/posts.
Every change here is live immediately — none of it goes through the draft-and-publish loop — and every one is an event: post.*, redirect.* and seo_override.*, the last with the resource id <ownerType>:<handle>.
Analytics and search performance
One call answers "how is the store doing?" (analytics). get_analytics { from?, to?, compare? } (orders:read) returns a period's revenueMinor, orders, paidOrders, averageOrderMinor, refundedMinor, abandonedCheckouts, abandonedRate, newCustomers, repeatRate, bestSellers, views (total, the visitor hint, a breakdown by page kind, the busiest paths), topReferrers, checkoutViews and conversion, plus compareTo — the same shape for the period of equal length immediately before — when compare is set. from/to are ISO days in the store's timezone (UTC when it has none), at most 366 days apart, defaulting to the last 30 days. The platform route is GET /api/analytics?storeId=&from=&to=&compare= with the same scope; get_top_products is the short form for the best sellers alone. Measure before and after a change: it is the only way to tell a good edit from a confident one.
Storefront views are counted inside the store with no cookie, no fingerprint and no visitor row: a per-day counter for each path and each referring host (never the referring path, its query or any utm parameter), and a unique_hint derived from a two-day hash of the day, the caller's network (/24 or /48) and a browser family. Bots, API calls and media are skipped, and the count runs after the response so no page is slower for it.
export_analytics { period: "7d" | "30d" | "90d" | "365d" } hands the same period back as one CSV: a section column tells series (one row a day, with revenue, orders, views and checkout views), best_seller (one row a product) and referrer (one row a host) apart, so it drops into a spreadsheet or a monthly report unchanged. Merchants see the same numbers under Analytics, which is its own screen rather than a card on Home.
Where sales came from is one more call (order-sources). get_order_sources { from?, to? } (orders:read, same period rules) answers the Sales sources table: paid orders and sales per channel (direct, search, social, email, ads, referral, link, other), then per source, campaign and tracked link, with orders whose source was not recorded apart in unknown and since saying from when sources are recorded. Every order from list_orders, get_order and create_order carries cameFrom (the touch it is counted under, with first when the first visit differed); the ad click value is never returned.
Search performance comes from the search engines the store has connected, read once a day (describe_search_engines shows which; connect_search_engine asks the owner to connect one on the Search engines screen, see search-engines.md), and from reports the owner exports and the agent uploads with import_search_performance { rows: [{ day, query, page, clicks, impressions, position, engine?, country?, device? }] } (≤ 5,000 rows a call, upserted by day, engine, query, page, country and device), for an engine that is not connected or for older history. get_search_performance { from, to, groupBy?, engine?, country?, device?, limit? } reads it back grouped with an impression-weighted position. GET and POST /api/analytics/search are the same two calls (orders:read to read, settings:write to import), and the Analytics screen's Search performance card is a merchant doing it by hand: drop the console CSV on it and it maps the usual column spellings itself. The natural pairing is with per-page SEO above: read the numbers, find the pages with impressions and no clicks, rewrite those titles.
Checkout rules and customer groups
Three kinds of row decide what one cart is charged and what it is offered, and all three are data an agent can write (checkout-rules).
- Pricing rules — automatic discounts with no code for the shopper to type. Kinds:
percent,amount,bxgyandfree_shipping.appliessays which lines the discount comes off (all, chosenproductIds, orcollectionHandles);priorityorders them, lower first;stackabledecides whether they combine. Routes:/api/pricing-rules. - Checkout rules —
hide_rate,show_rate_only,hide_payment,pickupandlocal_delivery. A rule can only take an option away, or add one the merchant configured themselves: the store's zero-cost "Pickup at …" option, or local delivery attarget.feeMinorundertarget.rateName. It can never invent a price of its own. Routes:/api/checkout-rules. - Customer groups — a handle, a price list (
{ "<productId or variantId>": priceMinor }, or{ "%": -20 }for a percentage off list), a minimum order, a tax flag and payment terms.terms: "invoice"makes bank transfer the only payment method for that group. Routes:/api/customer-groups.
Both rule kinds share one condition grammar: minSubtotalMinor, minQuantity, firstOrder, customerGroup, customerTag, destinationCountries, destinationPostalCodes, productIds, collectionHandles and customField { ownerType, namespace, key, equals }. Every part you state must hold.
A pricing rule or a discount code may also carry conditions.experiment: { id, variant }; it then applies only to shoppers given that version of a running test (experiments). Checkout rules refuse it.
destinationPostalCodes is a list of postal-code prefixes the ship-to address must start with, compared with spaces, hyphens, dots and case removed, so SW1 matches sw1a 2aa. Formahand geocodes nothing: a local-delivery radius is written as the prefixes that ring the shop, and a minimum order is the ordinary minSubtotalMinor. A cart with no "Ship to" estimate matches no destination condition at all.
A discount never takes a line below zero, nor below the line's supplier.cost custom field when the store keeps one, and each line's discount is rounded to a whole number of minor units per unit in the shopper's favour — so the cart, the payment session and the order all carry the same integer price. The order records what applied, and the thank-you page shows it.
When there is no feature for it, build it as store code. A free gift with purchase, a bundle, a quantity break, a personal code after purchase, a loyalty discount, a progress bar: a store function decides (cart.transform adds lines, cart.price prices them and shows offers, order.event makes a code and mails it), an app keeps records and routes, and a storefront interaction presents it. For new agent-authored interactions, use section behavior and only the bounded APIs it exposes. Existing owner-authored legacy section scripts remain a more powerful same-origin option; they are not the safe agent behavior API. The platform clamps every function answer: store code names products and quantities, never prices. Worked examples include a free gift (store functions §6), a sign-up popup and spend-progress bar (legacy section scripts §9, §9b); describe_store_functions carries the free-gift function. Without code, a cart page body section places {% widget "offers" %}, {% widget "progress" %} and {% widget "quick-add" product: settings.gift %}, or draws from offers and cart.progress (describe_sections { parts: ["cart-page"] }). On the order confirmation page, a thankyou.content function shows up to three offers. Section interactions run only where enabled and not on protected checkout/account pages. An app granted shopper:identify may know the signed-in shopper at its own route; only the documented legacy script API can call it with api.shopper.fromApp(name, path) today. The daily code limits (2,000 per store, 200 per function, 500 per flow or app, 1,000 per token or person) are defaults: set_code_limits (settings:write) raises or lowers them up to 50,000 a day, and list_discounts shows the limits in force.
Check before you enable. simulate_checkout { items, customerEmail?, destinationCountry? } (settings:read; naming a customer also needs customers:read) runs the engine over a hypothetical cart and answers with the priced lines, the rules that would apply, and the shipping and payment options. It creates no order, holds no stock and counts no usage. Never switch a rule on without simulating the cart it is meant for and one it is not.
Conditions that name the shopper — customerGroup, customerTag, firstOrder — only hold for a shopper signed in to the store. An anonymous cart, and the public read API, see list prices: a group's prices reach checkout only with the short-lived proof the store itself mints for that shopper's cart.
Launch mode
A store can be kept private until the merchant is ready (launch-mode). To look at it meanwhile, get_preview_link (scope storefront:read, also GET /api/storefront-preview) answers { published: { url, expiresAt }, draft: { url, expiresAt }, launchMode }: two signed links, good for 24 hours, that open the storefront on any launch mode without changing it, one on what shoppers would see and one on the unpublished draft; nobody has to switch the store to password mode to see their own work. get_launch_mode reads { access: "open" | "password" | "coming_soon", message, hasPassword, updatedAt }; set_launch_mode { access, password?, message? } replaces it. While access is password or coming_soon, every storefront HTML page answers one themed "Coming soon" page with the merchant's message, a password box and the newsletter signup, 200 with X-Robots-Tag: noindex (never 401), robots.txt disallows everything and the sitemap carries the address alone. The storefront read API, the shopping feeds, signed download links and the shopper /account pages keep working, so a headless front end and a shopping engine are unaffected by a shop that has not opened yet.
The password is 4 to 40 characters, stored hashed, and comes back exactly once — in the answer to the call that set it. Closing a store that has never had one, without giving one, is refused rather than guessed at; opening never needs a password. Setting a new password signs every preview visitor out. coming_soon is that page without the password box, because a store that has never opened has nothing to preview: it is where every new store starts (readiness), and opening one goes through storefront_open readiness, so a shop with nothing to sell, no way to take money, no delivery or no policy pages is refused with the checklist. Stores that existed before that release stay open.
Checkout settings and staff notifications
What the payment page asks a shopper for is data an agent can write (payments). get_checkout_settings reads the row, set_checkout_settings replaces it whole: phone and company are hidden, optional or required, addressLine2 is optional or hidden, orderNote turns on a free-text "Note for the seller" that is stored on the order as customerNote, marketingOptIn is off, unchecked or checked, termsLink requires a tick beside the store's own Terms page, embedded decides where the shopper pays — true (the default) renders the payment form on the store's own /checkout page in the store's theme, false sends them to the processor's hosted page (payments) — and tipping is reserved and must stay false.
Three promises hold whatever an agent writes. Only consent subscribes a buyer: marketingOptIn shows a box, and a contact becomes subscribed only when that box comes back accepted — never because they bought something. The note belongs to the shopper: a store with orderNote off stores nothing, however a form is posted. The processor has the last word: a hosted payment page has no optional phone (once shown, it is required), never pre-ticks a consent box, and always shows the second address line, so checked and addressLine2: "hidden" reach only a front end you build yourself.
get_notification_settings / set_notification_settings name up to five extra addresses each that also receive the store's new-order and low-stock emails. The store owner receives those emails whatever the lists say, so these are additions, never replacements.
The weekly summary is a short mail every Monday at 08:00 store time to the owner (on unless they switch it off) and to admins who switch it on, for an open store that had at least one order or 20 visits that week; the test store never sends it. Each person's switch is their own, so an agent sets it only for the person who made its token. get_weekly_summary answers the same numbers the mail is built from, money in minor units of currency with the formatted amount beside it, and comparisons as ready-made words. There is no tool for "Send me a sample": mail into a person's inbox is asked for by that person, signed in, from Settings, Email sending (weekly summary).
Tax
Whether the store charges tax at all is data an agent can write (payments). get_tax_settings reads the row, set_tax_settings replaces it whole: enabled, pricesIncludeTax, defaultTaxCode and shippingTaxCode. A product overrides the default with its own taxCode on upsert_product.
Three things are worth knowing before writing any of it.
Nothing here calculates tax. The merchant's payments account holds the registrations and is liable for the money, so the processor works out every number. All these settings do is decide what to ask it. Until that account has tax switched on and a registration in the regions the merchant collects in, enabled: true still records a tax of 0 — describe_readiness carries a tax requirement that says exactly which of the two halves is missing, and it is a recommendation, never a blocker: plenty of stores owe no tax and take money perfectly well without it.
**pricesIncludeTax changes the money, not the wording.** true means the price on the product page is what the shopper pays and the tax is inside it (the EU/UK way); false adds it at checkout (the US way). Flipping it on a live store changes what every shopper is charged, so ask the merchant rather than inferring it. A store that has never saved the row already reads the answer its currency implies.
Tax categories are opaque. A code looks like txcd_99999999 and is never constructed, guessed or translated from another provider's code — the tool refuses anything that is not one. txcd_99999999 is general tangible goods, txcd_20030000 services, txcd_10000000 electronically supplied services, txcd_92010001 shipping, txcd_00000000 nontaxable; a product left at "" falls back to the store's default and then to the code its kind implies.
Tax also reaches the numbers an agent reads: get_analytics reports revenueMinor net of tax with taxCollectedMinor beside it, and export_orders carries order_tax, order_tax_rate, order_tax_jurisdictions and customer_tax_id. Tax is money the merchant holds for a tax authority, never theirs, so it is never counted as revenue.
Payments: preparing the payout profile
Getting paid is the one part of a store an agent cannot finish. Identity, bank account and terms are the merchant's own signature, on the processor's own form. Everything around that is ordinary store knowledge, and an agent that has already read the catalog and the domains answers it better than a merchant typing into a form — so get_payout_profile and set_payout_profile prepare the form and then hand over (payments).
get_payout_profile {} (settings:read) answers { profile, defaults, warnings, applied, leftover }. profile is what the merchant saved, or null; defaults is the draft Formahand built from the store's own record — category, the live custom domain or the free address, a written product description, the owner as representative and support contact — and is what to send back when there is nothing to correct. leftover names, in plain language, what the secure form will still ask for.
set_payout_profile { profile } (settings:write) replaces every field, so read first and send the whole object:
{
"businessType": "individual", // or "company", then businessName
"category": "clothing", // one of the curated keys, see get_payout_profile
"websiteMode": "store", // "other" + websiteUrl, or "none"
"productDescription": "…", // ≥ 40 characters when websiteMode is "none"
"chargeTiming": "at_checkout", // or "on_ship", "deposit"
"supportEmail": "orders@example.com",
"supportPhone": "+44…",
"representativeName": "Jane Doe",
"representativeEmail": "jane@example.com"
}
Where a payout account already exists the answers are sent to it at once, so the form opens shorter; where none exists they are sent the moment the merchant opens one. Neither tool completes onboarding and neither switches the store's active processor.
Read the warnings back to the merchant rather than swallowing them — a storefront still showing "Coming soon" is the common one, because the website on the account is reviewed and a site under construction is usually refused; open the store first, or choose websiteMode: "none" and write a description that stands on its own. Then hand over: the answer's handoff.href is /dashboard#settings/payments/setup, and only the merchant can finish there. describe_readiness { feature: "checkout" } carries the same thing as the payout_profile requirement, severity recommended.
Payments: the switches on the account
The payout profile is what an agent prepares before money moves. This is what an agent can change after: the features on the store's card-payments account, what each one costs the merchant, and which of them are actually theirs to decide (payments).
describe_payment_features {} (settings:read) reads them live from the account — nothing here is a flag Formahand keeps — and answers { ready, mode, features, handoff }. Each feature carries id, title, summary, cost, control, state, value, blockers and whatChanges, plus methods for the ways to pay and payouts for the schedule. Two fields decide what an agent should do next:
- **
control** is"you"when the merchant can change it here, and"provider"when the payment provider decides and the row is status only. Fraud screening and dispute cover are"provider": there are no rules to write, no allow or block lists to keep, andset_payment_featurerefuses them with the reason rather than pretending. - **
blockers** says, in words a merchant reads, what is stopping a feature. "You have no tax registrations yet, so every order is taxed at nothing until you add one" is the one worth repeating out loud.
set_payment_feature { feature, enabled, confirm } (payments:write) is two calls on purpose. With confirm: false — the default — nothing is touched and the answer is { applied: false, consequence }, one sentence saying what the change does and what the provider will charge for it. Read that to the merchant. Only then call again with confirm: true, which applies it and answers the refreshed list. An agent that skips the first call is asking the merchant to pay for something they were never told the price of.
| Feature | Extra arguments | What switching it on means |
|---|---|---|
automatic_tax | — | Orders are taxed where the merchant is registered, at 0.5% of each order. Registrations are theirs to add, and without one the order is taxed at nothing. |
strong_authentication | authentication (always, over, provider), authenticationOverMinor | Buyers are asked to confirm with their bank more often. No extra cost; fewer disputes, and a few buyers who give up at the bank's screen. |
local_currencies | — | Shoppers abroad pay in their own currency. Off until the merchant turns it on. Prices stay in the store's one currency and the storefront and cart show that currency; only the payment page converts, and the merchant is still paid in their own currency. Nothing extra for the merchant; the buyer's converted price carries the conversion charge. |
instant_payouts | payoutInterval (daily, weekly, monthly, manual) | The schedule is the switch: manual keeps the balance until the merchant asks, which is what makes an instant payout worth having. |
payment_methods | method (an id from describe_payment_features) | One way to pay on or off. Pay-later methods cost noticeably more per order and the rate is on the row. |
fraud_controls, chargeback_protection | — | Refused: the provider decides these. Report the state and the blockers. |
request_instant_payout { amountMinor?, currency? } (payments:write) sends the balance across now. It moves the merchant's money, so say the fee first — the provider charges a percentage of each instant payout, and Formahand adds nothing — and it is refused outright unless describe_payment_features shows instant_payouts with state: "on". Leave amountMinor out to send everything ready.
Tax registrations
list_tax_registrations {} (settings:read) answers where the store is registered to collect tax, read live from its payments account, with the attestation Formahand recorded for each one beside it. read: false means the account could not be asked — which is not the same as there being none, and the difference matters: where there is no registration, orders are taxed at nothing, silently. guard says whether tax is paused, locked or flagged.
add_tax_registration { country, state?, type, activeFrom?, confirm, attested } (payments:write) is two calls. With confirm: false nothing is touched and the answer carries the attestation — the exact sentence the merchant has to agree to — the effective date and the fee. Read all three back. Only then call again with confirm: true and attested: true.
An agent's registration binds the owner. The owner minted your token, so an attestation you make is theirs: the claim is recorded against the store with your token named as the actor, the owner is emailed every time an agent adds or ends one, and the owner carries the consequences of tax collected or not collected. Formahand does not check registrations with any tax authority, so a wrong one is a liability rather than a validation error — never add a registration the merchant has not told you they hold.
If the payment provider later rejects a registration, tax is paused for the whole store and the claim is marked disputed. Two disputed claims in ninety days and no token may switch tax back on at all, only a signed-in owner; three and the store is reviewed against the terms. country is a two-letter code; state is required for the United States and a Canadian province_standard; type must be one the country offers, and every refusal names the ones it does; activeFrom is "now" or YYYY-MM-DD, today or later — a registration is never backdated.
expire_tax_registration { registrationId, expiresAt?, confirm } (payments:write) ends one from a date. Nothing is deleted: an ended registration is still the record of what the store claimed.
list_payment_notices { limit } (events:read) is everything the provider has said about the account rather than about one order: disputes, fraud warnings, payouts that bounced, details the account still owes, changes to the tax setup. They are ordinary events on the store's log — payments.account_changed, payments.dispute, payments.fraud_warning, payments.payout_failed, payments.tax_notice — so a flow or a subscription reacts to them as they arrive rather than polling. severity: "action" means the merchant has to do something; a dueAt is a real deadline.
All of it acts on the token's own environment, so fh_test_ changes the sandbox account and fh_<mode>_ the live one.
Shopper accounts
Shoppers sign in to the store itself with no password (shopper-accounts). POST /account/login { email } on the store hostname emails a six-digit code and a one-time link; POST /account/verify { email, code } or the link opens a 30-day session — a first-party fh_shopper cookie on the store hostname, or a bearer token for a front end on another origin. /account, /account/orders/<id>, /account/profile and /account/addresses show that one shopper their own orders, tracking, digital downloads, marketing preference and default address, and the JSON equivalents live under /api/storefront/account for custom front ends (headless §8b).
describe_shopper_accounts reports whether sign-in is on and every route; revoke_shopper_sessions ends one shopper's sessions. list_customers and get_customer carry accountStatus (active when the shopper has a live session, none otherwise) and lastSeenAt. Signed-in shoppers are also what makes customer-group pricing reach a cart.
What an agent cannot do: read a sign-in code, a link secret or a session token (the store keeps only hashes of all three), sign in as a shopper, or read one shopper's orders while acting for another. The sign-in answer is identical for a known and an unknown address, so the login route is not an account oracle. Sign-in requests are capped at 5 an hour per address and 20 an hour per client, and a code expires 20 minutes after it is sent, after one use.
Headless: your own front end
A merchant can run the shop's front end somewhere else entirely — Next.js on Vercel, a static site, a page an agent built — and still sell through Formahand. Orders, stock, emails, the dashboard and every tool above stay exactly as for the hosted storefront. The copy-paste guide, the formahand.js client and a worked demo are in headless; this is the contract.
The storefront read API
Public, unauthenticated, served from the store's own hostname (<slug>.store-on-formahand.app, the primary custom domain, or <slug>-test.store-on-formahand.app for the sandbox). Every response carries Access-Control-Allow-Origin: *; 200s are cached 60 s except the order lookup. Same data the storefront renders.
| Endpoint | Returns |
|---|---|
GET /api/storefront/products | { store: { name, currency }, sort, total, products: [Product] }; ?collection=<handle>, ?q=<text>, ?sort=newest|price-asc|price-desc|name, ?limit= / ?offset= |
GET /api/storefront/products/<slug> | { store, product: Product & { options, variants, collections } } |
GET /api/storefront/collections | { store, collections: [{ handle, title, description, imageUrl, productCount, url }] } |
GET /api/storefront/collections/<handle> | { store, collection: { …, products: [Product] } }; ?sort=, ?limit= / ?offset= |
GET /api/storefront/pages | { pages: [{ slug, title, updatedAt, url }] }, published content pages in the storefront's order |
GET /api/storefront/pages/<slug> | { page: { slug, title, bodyHtml, updatedAt, url } }; drafts and unknown slugs are 404 { error } |
GET /api/storefront/posts | { posts: [{ slug, title, excerpt, coverImageUrl, author, tags, publishedAt, updatedAt, url }] } — the store's blog (content-seo §4) |
GET /api/storefront/posts/<slug> | { post: { …, bodyHtml } }, the Markdown already rendered; drafts are 404 |
GET /api/storefront/navigation | { header: [{ label, href }], footer: [{ label, href }] }: the merchant's links for each location, else that theme's own default links |
GET /api/storefront/orders?session_id=<cs_…> | { order } for a thank-you page; Cache-Control: no-store |
GET / POST /api/storefront/account/* | The signed-in shopper's own orders, profile and address (shopper-accounts) |
Paging: without limit or offset a listing returns everything. With either, limit is 1–100 (100 when only offset is given), offset ≥ 0, and the response also carries total, limit, offset and nextOffset (null on the last page); malformed values are 400 { error }.
Order lookup: session_id must look like a checkout session id (cs_…); orders paid by bank transfer or cash on delivery are found with ?order_id=<id>&email=<buyer email> (exact match, case-insensitive). The view is redacted: { order: { number, status, fulfillmentStatus, currency, subtotalMinor, shippingMinor, discountMinor, taxMinor, totalMinor, items: [{ name, variant, quantity, unitPriceMinor, imageUrl }], emailMasked, trackingUrl, createdAt } }, never the address, phone or full email (emailMasked is s***r@example.com); trackingUrl is the buyer tracking page once a tracking number exists, else null. Unknown orders are 404 { error }; a missing or malformed parameter is 400.
Checkout from your own origin
- Allowed origins.
POST /api/checkoutrefuses any request whoseOrigin/Refereris not one of the store's own hostnames unless the request'sOriginheader exactly equals an origin listed for the store. Online Store → Domains → Custom front ends manages the list; so doGET/PUT /api/checkout-originsand the toolsget_checkout_origins/set_checkout_origins. An origin ishttps://host[:port](lowercase, no path);http://localhost[:port]is accepted for development. Up to 10 per store.PUT { storeId?, origins: string[] }replaces the whole list; invalid entries are 400 and nothing is written. - The checkout call. From a page on an allowed origin:
fetch("https://formahand.com/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
storeId, // the store id (get_store)
items: [{ productSlug, quantity, variantId }], // variantId only for a product with options
successUrl: "https://shop.example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
cancelUrl: "https://shop.example.com/cart",
}),
})
.then((r) => r.json())
.then(({ url, error }) => (url ? location.assign(url) : showError(error)));
The body is what parseCheckoutRequest accepts: storeId plus either items (1–20 lines of { productSlug, quantity (1–10), variantId? }; duplicate lines are merged) or the single-product shorthand { productSlug, quantity?, variantId? }. destinationCountry / destinationPostalCode may be added for a live-rate shipping estimate, and email prefills the payment page for a signed-in shopper. mode: "test" sends the checkout to the store's sandbox (its own data, its own test payment account and test-mode processor keys; environments) and is honoured only for an allowed origin: the hosted storefront's hostname already names its environment, so a form post carrying the field is unaffected. Absent, blank or "live" is the live store; any other value is 400 { error }.
The response for an allowed-origin JSON caller is 200 { url } with the hosted payment URL (the hosted storefront's form posts still get a 303 redirect); errors are { error } with 400 (bad items or return URLs), 403 (origin not allowed), 409 (store not ready, out of stock, mixed currencies), 502/503 (the payment processor or the store did not answer). Every response to an allowed origin carries Access-Control-Allow-Origin: <that origin>, Vary: Origin, Access-Control-Allow-Headers: Content-Type and Access-Control-Allow-Methods: POST, OPTIONS; OPTIONS /api/checkout answers 204 for a listed origin (optionally narrowed with ?storeId=) and 403 otherwise.
successUrl and cancelUrl are optional. Each must be an absolute URL on one of the store's allowed origins, else 400; the processor substitutes {CHECKOUT_SESSION_ID} in successUrl. Without them the shopper returns to the hosted storefront's /checkout/success page and, on cancel, to the front end's origin root.
Newsletter, currency and shopper sign-in from another origin
- Newsletter.
POST /api/newsletteron the store hostname also takesContent-Type: application/json{ email, name? }and answers200 { ok: true }or400 { error }withAccess-Control-Allow-Origin: *;OPTIONSis a 204 preflight. The theme's form post (form-encoded, 303 redirect) is unchanged; both honour thecompanyhoneypot (a filled one is answered as success and stored nowhere). The endpoint is public like the rest of the read API: the allowed-origin list governs checkout only, and the newsletter form never consults it. - Display currency.
<script src="https://formahand.com/js/currency.js" data-base="USD" data-currencies="…">formats every[data-price]element in the shopper's own currency on a site you built (a hosted storefront takes no script tag of yours, and its policy would refuse this one — headless §5b); rates come fromGET /api/rates?base=USD(public, CORS open, daily reference rates). Display only — checkout always charges the store currency, and a converted price must never be sent back (headless §5b). - Shopper accounts.
POST /api/storefront/account/login→verify→ a 30-day bearer token, thenGET /api/storefront/account,/orders/<id>,PUT /profile,GET/PUT /addresses,POST /logout. The token is the shopper; these routes answer any origin and never with credentials, so the store's own cookie is never sent from your site (headless §8b).
Limits: hosted payment pages only (no embedded payment element); the cart lives in the front end — Formahand prices the lines from the store's catalog at checkout time, reserves stock for the session and records the order when payment confirms, so a front end never sends prices. Promotion codes are entered on the payment page. A store must have payments set up and a ready runtime, exactly as for the hosted storefront. Plugins and bots act with scoped tokens and hear about the store through create_subscription.
Shopping feeds and the agent-readable store
Every store publishes its own catalog for shopping engines and AI agents with nothing installed (feeds): /feeds/google.xml (RSS 2.0 with the g: namespace), /feeds/products.csv and /feeds/products.json carry one row per buyable item — a plain product once, a product with options once per variant, sharing an item_group_id — and /llms.txt with /.well-known/formahand-store.json describe the store, its currency, its shipping countries, the read API and how a purchase happens. All five are public and need no token; the feeds are cached ten minutes, marked noindex, and left out of the sitemap. The same rows are also published for the Meta, Pinterest and TikTok catalogues at /feeds/meta.csv (and /feeds/meta.xml), /feeds/pinterest.xml and /feeds/tiktok.csv, each link tagged utm_source=<channel>&utm_medium=feed.
For an agent working on the store: get_feeds (settings:read) returns the five addresses, the three channel feeds (channelFeeds) and a summary, and check_feed (settings:read) fetches the published feed and lists the first five problems a shopping engine would reject (no id, title, description, link, photo or price), with problemCount for the total. check_feed { channel } checks for one of Google, Meta, Pinterest or TikTok and lists what that channel would reject or warn about, each with a fix (feeds "Feed health"). GET /api/feeds/check?storeId= (with &channel=) is the same check over HTTP, and it is what Check feed in Analytics → Product feeds calls. Fix what it names with upsert_product and set_product_image, then run it again.
A store hidden from search engines answers 404 on all nine feed and agent addresses, and check_feed reports that as an error rather than as an empty catalog.
Environments: sandbox, promote, refresh
A store has a live environment and, once enabled, a sandbox: a second, completely separate store at <slug>-test.<domain> with test payment keys, free test labels and email routed only to the owner with a "[Test]" subject. Orders there are not real, never meter usage, never bill and never hit a plan wall. A token acts on exactly one environment, fixed by its prefix (environments).
- With an
fh_<mode>_token (settings:write):enable_sandbox, thenget_environmentuntilsandbox.runtimeStatusisready(about a minute). - Mint an
fh_test_token in Settings → Agents & API (choose Test).get_storeanswersmode: "test"and the<slug>-testhostname;get_startedlists what the sandbox still needs. - Build there:
set_shipping,upsert_product+set_product_image, rules, flows,get_storefront_draft→ edits →publish_storefront. A checkout on the sandbox hostname uses test-mode payments; the confirmation email reaches the owner as[Test] …. promote_to_live { dryRun: true }, read the summary, thenpromote_to_live. Catalog and settings are live at once; the storefront arrives as a new live draft.- With the live token:
get_storefront_draft→publish_storefront { revision }. Nothing shoppers see changed before this call.
promote_to_live copies products with variants, images and custom fields, collections, pages, navigation, SEO, shipping settings and rates, discounts (recreated on the live payment account), email templates, flows (disabled on arrival, secrets blank) and integration configs without secrets. Never copied: orders, customers, events, subscriptions, labels, statements, secrets. It is idempotent by slug, handle, key, name and code; the summary lists created/updated/skipped and conflicts per resource. refresh_sandbox goes the other way and publishes in the sandbox. Live-only: custom domains, plan and billing, statements, the merchant's card.
Custom domains
An agent with domains:read and domains:write connects the merchant's own hostname without the merchant touching DNS records (domains has the full walkthrough and the response shapes):
describe_domain_setup { hostname: "shop.example.com" }— the records (CNAME shop → customers.formahand.com, DNS only), the provider detected from the nameservers with whether it allows a CNAME on a bare domain, and ready-to-run commands whose credentials are placeholders.add_domain { hostname }— the domain ispending; a bare domain also getswww(www: falseto skip), and the response repeatssetup.- Create the records on the merchant's machine with the merchant's own credentials (
$DNS_API_TOKEN, an authenticatedawsorgcloud), or give the merchant thegenericrecord for the DNS panel. Never ask the merchant to paste DNS credentials into Formahand. Whenprovider.domainConnectUrlis present, opening it in the merchant's browser adds the record after they approve it at the provider. check_domain { id }every 30–60 s untildomain.statusisactive(domain.errorsquotes the reason meanwhile); the certificate issues automatically.make_primary_domain { id }if the merchant wants it as the canonical address; the free*.store-on-formahand.appaddress and any other hostname then308to it.
The Domains tab hands the same task to an agent: after Add domain, Let Claude Code or Codex add the record opens the launch buttons with a ready-made domain-setup prompt.
Governance: expiry, propose mode, anomaly pauses
Added 2026-09-16. Scopes say what a token may touch. Governance says for how long, whether it acts or only asks, and what happens when it goes wrong — the three answers a merchant needs before handing a store to an agent for months rather than an afternoon. describe_governance reports all three for the calling token, so an agent can explain itself instead of retrying; call it at the start of a long run.
Expiry. A token may carry an end date: POST /api/tokens { expiresInDays }, 1–365, or omitted for a token that never expires (the mint form calls it "stops working after"). The tokens list shows it, and verification refuses the token from that moment; on /api/* the refusal is **401 { error, reason: "expired" }**, so an agent can tell a stale credential from a wrong one and ask the merchant for a new token rather than re-trying. Three days before it expires, the owner is emailed once. Nothing is deleted: an expired token is simply inert.
Propose mode. A token minted (or later switched) with approvalMode: "propose" may read everything its scopes allow and write nothing. Every write tool call is stored instead of run:
{ "proposed": true, "proposalId": "…", "summary": "upsert_product on “Blue mug”",
"note": "Stored, not run: this token is in propose mode and the change is awaiting the owner's approval in Settings → Agents & API." }
The proposal keeps the tool, the exact arguments, a summary and a status of pending, approved, rejected, expired or failed. The owner approves or rejects it in Settings → Agents & API; approving replays the stored call with that same token's scopes and environment through the same tools /mcp exposes, and keeps the result with the proposal (a replay that fails is recorded as failed, with the error, rather than silently vanishing). A pending proposal lapses after seven days. Four tools keep the agent in the loop — list_proposals, get_proposal, withdraw_proposal and describe_governance — and are never themselves proposed. Propose mode is not a scope: a token with products:write in propose mode still needs that scope, it just cannot use it unattended.
Anomaly pauses. Two shapes of trouble pause a token on their own:
- A write burst. Writes are counted per token per UTC minute, tool calls and REST writes alike. Above the store's allowance — 120 a minute by default, adjustable down to 20 in Settings → Agents & API — the token is paused.
- An error loop. 25 consecutive failing tool calls inside five minutes means an agent is retrying something that will never work; the token is paused rather than left to burn the day.
A paused token answers **403 { error, reason: "paused", steps }** on /api/* and the same body as an isError tool result on /mcp; paused_at and paused_reason say when and why. The owner is emailed once and resumes (or revokes) the token from the dashboard — never the agent, and never the token itself. Resuming clears the counters.
GET /api/agent-governance (session only) returns { tokens, settings, defaults } and PUT changes one token's approvalMode or expiresInDays, resumes it with { paused: false }, or writes the store's writesPerMinute threshold (null restores the default). GET/POST /api/agent-proposals list and decide proposals. Both refuse tokens, for the reason the table below gives.
Auditing an agent
Every write a token makes is recorded with the actor token:<id>, so GET /api/events?actor=token:<id>&limit=50 (and list_events { actor }) is one agent's complete trail: what it changed, when, and which fields. A trailing * matches a prefix (actor=token:* for every agent, actor=system:* for the platform's own jobs). Settings → Agents & API shows the same thing as What your agents did: every token with its last activity, the last 50 changes of the one you pick, and the revoke button next to it.
In propose mode the REST routes refuse writes outright with 403 { error, reason: "propose" } (there is no stored call to replay there); only MCP tool calls become proposals. describe_governance reports which mode the token is in.
Walls, statements and rate limits
Some features are walled by the store's plan or by setup it has not finished (billing §4). The refusal is one JSON shape on every surface:
{ "error": "…", "wall": true, "feature": "marketing", "reason": "setup", "plan": "free",
"requiredPlan": "growth",
"steps": [{ "id": "domain", "label": "A custom domain that is live on this store", "done": false,
"href": "/dashboard#settings/domains", "tool": "add_domain", "detail": "No custom domain yet…" }],
"warnings": ["Add a DMARC record …"], "usage": { "used": 3, "limit": 3 } }
Routes answer 402 when reason is plan (upgrade: start_upgrade or POST /api/plan { action: "upgrade", plan: requiredPlan }, and hand the URL to the merchant) and 409 for setup (complete the steps; tool names the MCP tool when an agent can do it, href the dashboard place otherwise — the payment profile is always the merchant's step), paused (the merchant asks for a review) and limit (wait, remove, switch the feature on, or upgrade when requiredPlan is present). MCP tools return the same body as an isError result. Walled today: POST /api/campaigns (marketing), POST /api/domains / add_domain (customDomains, beyond the allowance without a payment profile), POST /api/product-images and the image tools (media, Free past 10 GB without one), POST /api/setup (assistant), POST /api/onboarding (stores). Flows and tokens are not walled. describe_walls lists every feature with its current state before you try; get_plan adds pricing, usage and the running statement, and get_statement reads a month. The sandbox has no walls.
describe_account: the whole situation, and what to say
A wall says what is refused. describe_account says what the account looks like and what to ask the merchant for, in one call (billing §4a). Call it whenever a tool answers 402 or 409, or a meter is close to its limit:
{ "plan": { "id": "free", "name": "Free", "monthlyUsd": 0, "status": "free" },
"paymentProfile": { "state": "none", "onFile": false, "href": "https://formahand.com/dashboard#settings/plan/payment", "commitments": [] },
"statement": { "period": "2026-09", "totalMinor": 0, "currency": "usd", "status": "running" },
"features": [{ "id": "email", "title": "Email from your own domain", "state": "needs_card",
"used": 480, "limit": 500, "unit": "emails", "currentOption": "free",
"options": [{ "id": "starter", "label": "1,500 emails a month", "priceLine": "$5 a month", "monthlyUsd": 5, "requiresCard": true }],
"activateHref": "https://formahand.com/dashboard#activate/email?option=starter",
"agentCanDo": "Read the meter … Choosing a package or adding a payment method is the merchant's.",
"askMerchant": "Your store has used 480 of 500 emails this month. Before it runs out, add a payment method to your payment profile at https://formahand.com/dashboard#settings/plan/payment first, then choose 1,500 emails a month at https://formahand.com/dashboard#activate/email?option=starter. Only you can add it." }],
"nextSteps": ["…"] }
state is one of active, available, needs_card, needs_plan, limit_reached. agentCanDo is what your token may do by itself; askMerchant is a sentence to say as it stands, with the deep link.
**Read paymentProfile.state before you suggest anything that costs money.** It is none, ready or expired, and askMerchant is already written for it: with ready the sentence says the package "will be billed to your card on file"; with none it asks the merchant to add a payment method at the profile link first, then choose the option; with expired it asks them to replace the card first. A feature that is already active is never offered again — it says so and stops. nextSteps puts the payment profile first whenever it blocks anything, so following the list in order never asks a merchant to pay for something twice.
An agent never adds a payment method, chooses a paid package or pays; start_upgrade only opens the page the merchant pays on, and describe_readiness requirements carry the same activateHref where one applies.
Rate limits. Every token gets a burst allowance of 30 requests refilling at 10 a second and, on top of that, a shared sliding window of 60 seconds, so the limit is the same however many places your calls land in:
| Scope | Budget per minute |
|---|---|
| One token | 600 |
| One store, across all its tokens | 3,000 |
One IP on POST /api/checkout | 120 |
Ordinary traffic never pays for the shared check: each server answering your calls counts locally and only consults the shared counter once it has spent 20% of a budget. A refusal is a 429 with Retry-After, the scope that ran out and the limit:
{ "error": "Too many requests: 600 a minute for this token. Retry in 43s.", "scope": "token", "limit": 600, "retryAfterSeconds": 43 }
If the shared counter is unavailable the request is allowed and the failure is logged once: a limiter that fails closed would take stores down to protect them. The storefront read API and the storefront's own /api/newsletter are served by the store itself, not by formahand.com, and keep their own guards.
Delivery budgets. Outbound deliveries — event subscriptions and the webhook / wake_agent actions flows take — are budgeted per store per day: Free 2,000, Growth 20,000, Pro 100,000. Over the budget, nothing is dropped: the due deliveries stay queued in your store, the platform stops driving them until the next UTC midnight, and the owner is emailed once that day. Storage is walled by the plan as before.
Taking everything with you
POST /api/store/export (session, owner or admin) queues a full export: products, collections, orders, customers, pages, posts, flows, subscriptions, discounts, email templates, settings (the storefront draft as schema version 3) and the store's sections with every kept version (sections.json), each as a JSON file, zipped with a manifest.json that lists the files and their record counts. The archive is built within ten minutes and the owner is emailed a signed link that works for 24 hours; GET /api/store/export lists recent exports for the dashboard, and the emailed link is authenticated by its signature alone, so it opens from a mail client. Every file is built from the same reads the dashboard makes, so an export can never hold more than the merchant can already see.
An agent cannot mint that link, but it can assemble the same material from the tools and routes it already has (list_products, list_orders, /api/orders?format=csv, export_customers, list_posts, list_flows, …). Tell the merchant to press Export in Settings when they want the signed archive.
Closing a store
POST /api/store/delete { confirmName } (session, owner only, same origin) starts a seven-day window:
- the storefront serves a closing page (
503,noindex) in both environments; - every API token is revoked;
- every event subscription is paused;
- the owner is emailed the date, and the store reports
status: "deleting"withdaysLeft.
POST /api/store/delete/cancel inside the window brings the store back — the revoked tokens stay revoked, and the response says so. On the day both environments go for good — the store's data, its media and the account's record of the store. Nothing here is reachable with a token, at any scope: an agent can read everything, and hand the owner an export, but only a person can end the business.
Never possible with a token
Everything the dashboard can do to a store, a token can do — with these exceptions, which need a signed-in person. They answer 401 ("Sign in to …") or 403 to a bearer token, and they are the only /api/* routes with no scope. Every /api/* route is checked against this table on every build, so a new route must either carry a scope or land in this list.
| Route | Why it needs the merchant |
|---|---|
/api | The public index of the API: where the reference, the docs and the MCP endpoint are, and every tool with its scope. It holds no store data, so it needs no token. |
/api/openapi.json | The public API reference: every route a token can call and the scope each needs. It holds no store data, so it needs no token. |
/api/agent-limits | The ceiling on what agents may refund; a token raising its own limit would be no limit at all. |
/api/section-scripts | Whether scripts in sections run on the storefront. Code an agent wrote would otherwise be switching itself on; only the merchant, signed in, decides that. |
/api/pixels | The Pixels screen for a signed-in owner or admin. An agent does the same with get_pixels, set_pixel, remove_pixel and set_consent_mode under its own scopes, except turning asking off, which only the owner chooses. |
/api/agent-governance | Expiry, propose mode, pause and resume: an agent that could unpause or promote itself is not governed at all. |
/api/agent-proposals | Approving what an agent asked to do is the second signature propose mode exists for; the asker cannot also answer. |
/api/assist | Writing help spends the platform's own model budget on the signed-in merchant's behalf; an agent writes its own copy and sends it with upsert_product. |
/api/assistant | The assistant acts as the signed-in merchant with all their scopes, in the dashboard's own environment; a token holder already has the tools directly and has no business borrowing a person's authority. |
/api/assistant/confirm | Approving a change the assistant stopped on is the merchant's own decision, in the same breath as seeing it; an agent that could approve its own writes would make ask mode theatre. |
/api/auth/* | Signing in, signing out and the session itself — what a token replaces. |
/api/catalog-sheet | Connects the merchant's own spreadsheet with the Google account they signed in with. |
/api/checkout | The shopper's checkout, started by a storefront page from an allowed origin, not by an agent. |
/api/consent | Accepting the terms is the merchant's signature and cannot be delegated. |
/api/domains/return | The browser landing page a DNS provider redirects to after the merchant approves the records. |
/api/integrations/oauth/callback | The browser redirect back from a courier platform's consent screen. |
/api/integrations/oauth/start | Starts that consent screen in the merchant's own browser session. |
/api/connections/oauth/callback | The browser redirect back from a channel's consent screen. |
/api/connections/oauth/start | Starts that consent screen in the merchant's own browser session; connecting a channel is never a token's step. |
/api/model-settings | Which model answers, and whose key pays for it: a spending decision for the person who holds the account, not for the agent whose turns it would be paying for. |
/api/shop-preview | Private pre-signup design preview: browser capability before claim and the signed-in owner afterwards; tokens cannot claim a person’s draft. |
/api/onboarding | Creating a store: the person who signs up owns it and accepts its terms. |
/api/operator/demo-stores | Creates, refreshes and removes the theme demo stores, for Formahand's operator only; it answers no token, no session and no merchant. |
/api/operator/managed-stores | Opens showcase stores, opens or closes their public links and lists them in the showcase library, for Formahand's operator only (or Formahand's own admin CRM with its service secret); it answers no store token and no merchant. |
/api/operator/search-performance | Search performance across every store, for Formahand's operator only; it answers no token and no merchant. |
/api/payments/account-session | The key that opens the merchant's own payout, balance and payment screens in their browser; an agent holding it would be reading the money, not the catalog. |
/api/payments/country | Where the business is registered: the merchant's own answer, which decides which processors they are offered. |
/api/payments/connect | Payments onboarding: only the merchant can give their own business and bank details. |
/api/payments/paypal/connect | Connecting a PayPal account is the merchant signing in at PayPal and accepting PayPal's own agreement; nobody can do that on their behalf. |
/api/payments/paypal/onboarded | The browser redirect back from PayPal when the merchant has finished connecting their account. |
/api/payments/paypal/return | The browser redirect back from PayPal after the merchant approves the connection. |
/api/payments/provider/test | A live credential check the merchant runs while typing their processor keys. |
/api/paypal/partner-webhook | PayPal's callback about the sellers connected through Formahand, authenticated by its own signature, not by a token. |
/api/paypal/webhook | PayPal's own callback into the store, authenticated by its signature, not by a token. |
/api/rates | Public reference rates for the storefront's display-currency module; no store data, no token. |
/api/search-engines | Connecting a search engine records the owner's consent to Formahand reading the store's search data; consent is a person's act, so an agent can only ask (connect_search_engine) and read (describe_search_engines). |
/api/seo-insights | The SEO insights screen for a signed-in owner or admin. An agent reads the same report with get_seo_insights, under its own scopes; the screen only proposes and changes nothing. |
/api/sender | The dashboard's own door to Sending settings, on a session and a same-origin request. An agent is not shut out of sending: get_sender, set_sender, verify_sender and send_test_email do the same work on the token's own store, under the same domain-ownership rule. |
/api/setup | The setup assistant acts as the merchant across their whole account, not as one store's token. |
/api/shipping/formahand/return | The browser return from the hosted page where the merchant adds their card. |
/api/showcase-library | The Themes gallery's list of showcase looks, for a signed-in merchant; an agent reads the same list in list_themes and takes one with claim_showcase_look. |
/api/showcase | Taking the look of a showcase store for your own store: creating a store and accepting its terms are the person's own acts. |
/api/store-groups | A store group and its strategy stores: opening a new store counts against the plan and is the owner's own act; agents use the store group tools. |
/api/store-flags | The operator's own queue of stores that need a person to look at them. It reads across every store, so it answers only to the signed-in platform operator — a merchant cannot see it either, and a token that could would be reading other people's stores. |
/api/stripe/webhook | Stripe's own payment callback, authenticated by its signature. |
/api/stripe/connect-webhook | Stripe's callback about connected accounts (onboarding state, payouts). |
/api/stripe/test/webhook | The same payment callback for the sandbox's test mode. |
/api/stripe/test/connect-webhook | The same connected-account callback for the sandbox's test mode. |
/api/store/delete | Ending the business: the owner types the store's name, and a token that could delete its own store would be a catastrophe waiting for a bug. |
/api/store/delete/cancel | Stopping that deletion is the same decision in reverse, so it meets the same bar: the signed-in owner, on a Formahand page. |
/api/stores | The account's own stores and which one the dashboard is showing: a token is issued to one store and has no business enumerating the rest of the person's business, let alone moving their browser onto another. |
/api/store/export | One signed link to everything the store holds — catalog, orders, customers — is the merchant's to hand out, not an agent's to mint. |
/api/team | Inviting, promoting and removing people: who may act on the store is the owner's decision. |
/api/tokens | Minting and revoking tokens; a token that could mint tokens would outlive being revoked. |
/api/waitlist | The public sign-up form on the marketing site; it belongs to no store. |
/api/weekly-summary/unsubscribe | The unsubscribe link in the weekly summary mail: the signed link itself is the proof, so it works from any mail client without signing in, and it can only switch that one mail off for that one person. |
In short: minting or revoking tokens, changing team members, accepting the terms, payment onboarding, adding a card, paying a statement, setting the agent refund limits, changing a token's own governance, approving its own proposals, choosing the dashboard's environment, exporting the whole store and closing it. Everything else — customers, campaigns, refunds under the limit, pricing rules, posts, recipes, secrets, the sandbox and promote — is token work. Secrets never come back out either: integrations, flows, subscriptions and the vault report only that a secret is set.
Security boundaries
- No code upload, SQL, or shell: agents only get the typed command set and the same validated routes as the dashboard.
- Tokens are hashed at rest; the plaintext exists once, in the creation response. Revocation is immediate, and a token can also expire, be paused or be held in propose mode (Governance).
- A token is bound to one store, one environment (its prefix) and explicit scopes; session-only routes never accept it. Customers and campaigns are token-reachable with the
customers:*andmarketing:sendscopes and meet the same marketing wall as the dashboard. Domains are token-reachable only with thedomains:*scopes, and DNS credentials never pass through Formahand: the domain tools hand the agent commands with placeholders to run on the merchant's own machine. - Publication is explicit (
publish_storefront/POST /api/storefront-editor), revision-checked, and needs its own scope. Editing a draft never changes what shoppers see. - Money out is doubly bounded: the
orders:refundscope, then the owner's per-refund and per-day limits. - Every URL the store itself will call is checked against the outbound policy (public https, no credentials in the URL, no private or internal address, never one of our own domains).
- The MCP handler logs tool names on unexpected failures, never tokens, arguments, or store data.
Agent hand-off from the dashboard
Bring your own agent
Onboarding runs Account → Your store (seller level, ship-from country, description) → Set-up method → agent hand-off or live. The Bold Street theme (shopco) is picked automatically; products and shipping are no longer onboarding steps. The built-in assistant is hidden for now, so Use my own agent is the only assisted path: one click POSTs /api/tokens with the name "Store setup agent" and scopes storefront:read/write/publish, products:read/write, settings:read/write, orders:read, shows the token once, and renders paste-ready config for Claude Code (claude mcp add …), Codex (~/.codex/config.toml with [mcp_servers.formahand]; the header table name varies by Codex version, so the UI points at the Codex MCP docs rather than guessing) and generic MCP JSON, plus the first prompt Set up my store: <description>.
The hand-off offers three ways to start: Open in Claude Code (claude-cli://open?q=<prompt>, the desktop/CLI deep link), Open in Codex (codex://new?prompt=<prompt>, which opens the Codex app on this computer) and Copy prompt. The links only work where the app is installed and registered as the scheme handler; the copy button is the fallback. The agent then follows Set up a whole store above.
Ask your agent, from any page
Every dashboard page (Home, Products, Orders, Discounts, Integrations, Online Store, Pages, SEO, Customers, Email, Automations, Settings) carries an Ask your agent bar under its heading. It offers page-specific tasks (for example Sell a digital product, Refund or cancel, Set up automatic discounts, Create a trade price list, Start a blog, Install a recipe, Weekly performance report), an editable prompt, and the same Open in Claude Code / Open in Codex / Copy prompt buttons as onboarding. Each prompt opens with the store name, hostname and the selected environment (the sandbox is named when the dashboard is in Test), tells the agent to call describe_system and get_started first, names the MCP tools the job needs, and closes with the claude mcp add … line for a session that has no server yet. Include a store key for this task mints a token named Agent · <task> limited to that task's scopes and embeds it in that closing line; otherwise the prompt tells the agent to ask the merchant for a key. Every tool and scope those prompts name is checked against the live tool and scope lists on each build, so a prompt never names one that has gone.
The dashboard assistant
Chat here. Every page's Ask your agent bar now has two buttons: Chat here opens the assistant drawer (carrying that page's task as the starter prompt), and Ask your agent still hands the prompt to Claude Code, Codex or any agent of the merchant's own. The drawer also offers Continue in Claude Code, which passes a summary of the conversation to the same launch buttons.
The assistant runs the store's own MCP tools in-process, on the merchant's session. There is no token: it acts with the owner's full scopes, in whichever environment the dashboard's Live | Test switch is on, under the actor assistant:<userId>, which is what the store records against every write it makes (list_events shows assistant:<userId> beside token:<id>). Because it is a session, token governance — expiry, propose mode, anomaly pauses — does not apply; the approval gate below is its equivalent. It can call nothing but the MCP tools catalogued above, so no session-only route is reachable from a chat.
Ask vs act. The mode toggle in the drawer is Ask before changes (the default) or Just do it.
| Read-only tools (the ones whose description says read-only) | Everything else | |
|---|---|---|
| Ask before changes | run | the loop stops and shows the tool, a one-line summary and the arguments; nothing runs until the merchant presses Approve or Skip |
| Just do it | run | run |
A skip is final for that step: the model is told the merchant did not approve it and not to try it again. A credential in the arguments (set_secret, install_recipe) is masked before the drawer ever sees it.
Contracts. Both routes are session-only (owners and admins) and answer with application/x-ndjson: one JSON event per line, so the merchant watches the turn happen.
GET /api/assistant?storeId=→{ available, storeId, mode, limits, model, conversation: { conversationId, turns, transcript, pending } | null }— what the drawer reopens with.modelis{ provider, model, source: "own-key" | "platform" | "none", choices, settings, countsAgainstAllowance, qualityNote, ownKeySecretName, help }(see Choosing a model).POST /api/assistant { storeId?, conversationId?, message, mode: "ask" | "act" }→ the turn.POST /api/assistant/confirm { conversationId, stepId, approve }→ the rest of the turn that paused. Approving never spends another turn.
Events: conversation (conversationId, turns), text, step (stepId, tool, status: running | done | failed, summary), confirm (stepId, tool, summary, arguments), done (reason: end | awaiting | tool-limit | time-limit | model-error, note?) and error (with the wall body when a wall stopped it).
Limits. 12 tool calls and 60 seconds per turn; 30 merchant messages per conversation; the transcript is kept to the last 40 entries and 200 KB. A tool that has failed twice in a turn is not run a third time, and the same tool with exactly the same arguments is not run more than twice in a turn — the assistant explains instead. Each merchant message counts one run on the store's assistant meter and meets the same wall as the setup assistant (billing §4); the sandbox is unlimited and never metered, and so is a turn the store pays for with its own model key (below).
Choosing a model
The drawer has a Model control. Two choices:
| Choice | What answers | Who pays |
|---|---|---|
| Formahand default | the model we run for everyone | us — each message counts on the store's assistant meter |
| Free community model | a free, tool-capable model on OpenRouter, picked from the list | the platform's own OpenRouter key, unless the store adds one of its own |
A store that adds its own free OpenRouter key runs its whole day on its own free quota: Use my own OpenRouter key in the drawer reads the key from the store's secrets vault, under the name openrouter_api_key (Settings → Agents & API → Vault — the vault is write-only, so the key is stored scrambled and never shown, echoed or logged again). Turns on the store's own key **do not count against the plan's assistant allowance**; every other turn does.
The free models on the list, as of 2026-09-17: openrouter/free (picks a healthy free model for you), stealth/union-alpha, nvidia/nemotron-3-ultra-550b-a55b:free and nvidia/nemotron-3-super-120b-a12b:free (reasoning), thinkingmachines/inkling:free (reasoning), nex-agi/nex-n2.5-pro:free (reasoning), google/gemma-4-31b-it:free, google/gemma-4-26b-a4b-it:free and cohere/north-mini-code:free. That list is the only thing a store may choose; an id that has since left it falls back to the default rather than failing the turn. The drawer says plainly: Free models are slower and make more mistakes; keep Ask before changes on.
Resolution order for one turn:
- The store's setting picks the provider. No setting means Formahand default.
- Free community model → the store's own key if Use my own key is on and the secret is set; otherwise the platform's OpenRouter key; otherwise the platform's Claude key with the default model.
- Formahand default → the platform's Claude key; otherwise the platform's OpenRouter key; otherwise the store's own key if it has one.
- Nothing configured → the drawer says exactly what to do: add your own free OpenRouter key in Settings → Agents & API → Vault as
openrouter_api_key, or ask the person running this deployment to install a platform key.
Weaker models get more rope. Every provider goes through one client, which converts the tool table to OpenAI function schemas and back, accepts a tool call whose arguments arrived as a JSON string, retries once on a rate limit or a server error, gives up after a minute and turns the provider's error body into a sentence a merchant can read. The loop itself tolerates three more mistakes a small model makes: a reply that types the call out as a fenced JSON block ( {"tool":"list_products","input":{}} ) is honoured as a tool call once per turn; a call to a tool that does not exist is answered with the closest real names; and a model that never calls a tool for a request that plainly needed one is simply shown as it is.
Writing help uses the same store choice, so a store on a free model writes its copy with it too.
GET /api/model-settings?storeId= → { settings, resolved, choices, ownKeySet, ownKeySecretName }; PUT { storeId?, provider, model?, useOwnKey? } writes it. Session only, owners and admins — which model answers and whose key pays is a spending decision, so there is no MCP tool for it either: an agent choosing its own model would be an odd thing to allow.
The prompt. The system prompt is this page (renderAgentGuide(), the same text describe_system returns), plus the store's current get_started state, plus the house rules: never invent data, read before you write, publish only when asked, explain in merchant language, and stop and explain rather than retry. The merchant's own words arrive wrapped in <merchant_message>…</merchant_message> and everything a tool returns is named as store data, never as instructions — the prompt says so, and the wrapper cannot be closed early from inside the message.
The conversation and the model choice are both kept with the store, so a pending approval survives a reload.
The built-in setup assistant
The last onboarding step (and the Home card while a store has no products) lets a merchant describe the store in plain words and get it seeded. POST /api/setup { storeId?, description (20–2000 chars), level, country, currency, force? } is session-only (owners and admins), needs a ready runtime, and answers 409 This store already has products; use Products or an agent instead. unless force: true. One run per store per 2 minutes (429); every run is recorded with its level, description, plan, result and status. GET /api/setup returns the latest run and whether the assistant is available; the Home checklist shows "Set up by assistant" once a run finished.
Formahand calls a model with one tool, propose_store_setup, forced through tool_choice. The tool's input schema is the plan: { heading, intro, announcement?, seo: { title, metaDescription }, collections[{ handle, title, description, tags }], products[{ name, slug, description, priceMinor|null, compareAtMinor|null, currency, inventory, tags, weightGrams|null, options?, variants? }], shipping: { allowedCountries, rates[] }, notes[] }. The system prompt writes in the merchant's language, never invents prices (priceMinor: null means "skipped, needs a price"), and keeps copy short. An invalid plan is sent back once as an is_error tool result with the validation issues; a second invalid plan is a typed INVALID_PLAN error (502).
What each level seeds:
| Level | UI label | Seeds |
|---|---|---|
starter | I'm just starting | heading + intro, shipping countries and 1–2 flat rates in the store currency, at most the products explicitly described, SEO title/description |
catalog | I have products to list | the above plus tags, 1–3 tag-based collections (and the collection grid), options and variants (sizes, colours) when implied |
pro | I run a store already | the above plus a stronger SEO pair and an announcement bar; products are left for CSV import unless listed |
The plan is then carried out through exactly the same calls the MCP tools make, in order: shipping PUT → products POST (null-price ones skipped) → collections PUT (automated, rules.tags) → SEO PUT → theme-editor GET → PATCH (set-copy, set-module-settings announcement-bar, set-module-settings collection-grid) → POST publish. It publishes. The store is brand new and nothing is public yet, so this is the one place publication is implicit; the card says so and everything can be changed afterwards. Every step's failure lands in result.skipped rather than aborting, and the response is { run, plan (summary), result: { created: { products, collections, rates }, skipped[], published, notes[] } }. Photos are never part of a plan; the result lists "add photos" and "needs a price" items.
Writing help
Beside the description editor, the search title and description fields and the store-wide Search & sharing fields, the dashboard offers Write with AI: POST /api/assist { storeId, kind, input: { name, notes?, existing?, tone? } } → { text }, with kind one of product-description, search-title, search-description, post-excerpt and announcement. It calls the same model the store's assistant uses (Choosing a model) with no tools at all, so it can never change the store: the text lands in one field and nothing is saved until the merchant saves what they were editing. The previous value stays behind an Undo link for 30 seconds, so their own words are never replaced without a way back.
The prompt's one rule is that it writes only from the facts it is given. It may not invent a material, a size, a weight, an ingredient, a country of origin, a certification, a price or a delivery time, and a fact the merchant did not state comes back as […] for them to fill in — a blank a merchant notices, rather than a plausible sentence they do not. Session only (/api/assist is in the table above) and 30 pieces an hour per store; an agent writes its own copy and sends it with upsert_product.