Build your own site or plugin

Formahand as the backend for a site you build yourself: catalog API, checkout from your pages, thank-you order lookup, events, and a prompt for your agent.

Checkout email confirmation and saved selections (2026-10-17)

Custom storefronts can opt into passwordless checkout verification. POST /api/storefront/account/checkout/request accepts {name,email,items:[{productSlug,variantId?,quantity,attributes?}],discountCode?,buyNow?,phase?}. It validates public catalog selections, writes a temporary draft, and sends an unpaid-selection summary plus a six-digit code and a one-use /pay#confirm=<id>.<secret> link. Client-supplied prices/titles are ignored. There is no inventory reservation, payment, marketing subscription or account creation at this point. Requests share the shopper login limits (5/email/hour, 20/IP/hour).

POST /api/storefront/account/checkout/verify takes {id,code} or {id,secret}. Only a valid, unspent proof creates a new passwordless customer (without overwriting an existing profile), opens a session, and marks the draft verified. Code/link validity is 20 minutes with five wrong attempts. Your same-origin frontend should strip the returned bearer and set a host-only HttpOnly/Secure/SameSite cookie. Link secrets stay in the URL fragment and are removed before POST; never put them in a query string or analytics event.

GET /api/storefront/account/checkout/<id> requires a verified shopper session matching that draft's email. POST /api/storefront/account/checkout/save uses the same ownership check and accepts the document above plus optional id and a complete shippingAddress matching the checkout-address contract. Verified drafts expire after seven days; stale rows are purged on the next draft write. They are not customer-address defaults. An email also includes /pay?resume=<id> for a verified shopper to reopen the selection; an id alone grants no access. Checkout must reload current products and reprice; the snapshot is not a price guarantee. All mutations require the storefront's exact Origin. Bodies, lines, notes and identifiers are bounded.

Test mail goes only to the store owner's inbox with a [Test] subject. A checkout verification request returns an error rather than claiming delivery when its own message was not immediately sent. Existing stores need the checkout-confirmation update before these endpoints are available.

Start in Test mode and verify prompt inbox delivery before making email confirmation mandatory on your Live checkout. Shared transactional sending has capacity limits; delayed delivery is unsuitable for a blocking OTP step. Bag discount entry/selection and honest shipping/tax disclosure can ship independently.

Written 2026-09-16. For merchants who want their shop on a site they (or their agent) build themselves, and for anyone writing a plugin or bot that reacts to a store. Formahand stays the commerce backend; you own the pages. Everything here is verified against the routes in agents.md, events.md and environments.md; the last section lists what is not built yet.

1. What you get

Keep the storefront Formahand hosts, or replace it with anything that can call two URLs: a Next.js site, a static page, a page in a site builder, a Discord bot. Either way Formahand keeps doing the work behind it:

Stays with FormahandYou bring
The catalog: products, variants, images, collections, search (a public JSON API on your store's hostname)The pages that show it
Checkout: pricing from the catalog, stock reservation, shipping rates, discount codes, payment (Formahand Payments hosted checkout, PayPal, or bank transfer / cash on delivery)A "Buy" button that calls one endpoint and follows the URL it returns
Orders, buyer emails, the dashboard, fulfilment, labels, trackingA thank-you page
The event log and signed webhooks for every changeWhatever should happen when an order comes in

Nothing you build sends prices. A front end names products and quantities; Formahand prices them at checkout time from the catalog.

2. Quick start in 5 steps

Two hostnames, and they are not interchangeable. Your store's data is read from your store's own hostname — <slug>.store-on-formahand.app, your custom domain, or <slug>-test.store-on-formahand.app for the sandbox. The platform API you post a checkout to is **formahand.com**. They are separate domains on purpose (domains.md "Two domains"): your storefront and anything you or Formahand runs on it never shares an origin family with the Formahand dashboard. Addresses on <slug>.formahand.com from before 2026-09-23 still work — they answer a permanent redirect to the new host, path and query intact — but hard-code the new one, and note that a fetch following a redirect is a wasted round trip on every call.

  1. Get your store id and allow your site's origin. Dashboard → Online Store → Domains → Custom front ends: add https://shop.example.com (up to 10 origins; http://localhost:3000 is fine while developing) and copy the snippet, which contains your store id. An agent does the same with get_store (the id) and set_checkout_origins { origins: ["https://shop.example.com"] }.
  2. Fetch products from your store's own hostname, no token needed: GET https://<your-store>.store-on-formahand.app/api/storefront/products (also works on your custom domain, and on the sandbox hostname).
  3. Render them. Each product has slug, name, description, priceMinor, currency, imageUrl, images, inventory, hasVariants. priceMinor is in the currency's minor unit (1999 = $19.99).
  4. Start checkout from a page on your allowed origin: POST https://formahand.com/api/checkout with { storeId, items, successUrl, cancelUrl }. The response is { url }; send the browser there.
  5. Thank the buyer. Set successUrl to a page on your site such as https://shop.example.com/thanks?session_id={CHECKOUT_SESSION_ID}. The checkout page fills in the session id; your page passes it to GET https://<your-store>.store-on-formahand.app/api/storefront/orders?session_id=… and shows the order number, items and totals it returns. Leave successUrl out and the buyer lands on the hosted storefront's own thank-you page instead.

3. The read API

Public, read-only, on the store's hostname (<slug>.store-on-formahand.app, your primary custom domain, or <slug>-test.store-on-formahand.app for the sandbox). Every response carries Access-Control-Allow-Origin: *, so a browser on any site can read it, and a 60-second public cache (the order lookup is never cached).

EndpointReturns
GET /api/storefront/products{ store: { name, currency }, sort, total, products: [Product] }. Query: ?collection=<handle>, ?q=<text> (name, description, SKU and tags; every word must match; 80 characters max), ?sort=newest|price-asc|price-desc|name
GET /api/storefront/products/<slug>{ store, product: Product & { options: [{ name, values }], variants: [Variant], collections: [handle] } }
GET /api/storefront/collections{ store, collections: [{ handle, title, description, imageUrl, productCount, url }] }
GET /api/storefront/collections/<handle>{ store, collection: { …, products: [Product] } } (accepts ?sort=, ?limit=, ?offset=)
GET /api/storefront/pages{ pages: [{ slug, title, updatedAt, url }] }: the published content pages (About, FAQ, …)
GET /api/storefront/pages/<slug>{ page: { slug, title, bodyHtml, updatedAt, url } }; bodyHtml is the rendered page body, safe to insert as is
GET /api/storefront/navigation{ header: [{ label, href }], footer: [{ label, href }] }: the links the hosted storefront shows, the merchant's own or the theme's defaults when none are set. href is a site-relative path (/pages/about, /collections/all) or an https:// URL: map paths onto your own routes
GET /api/storefront/orders?session_id=<id>{ order } for the thank-you page, see below

Product: id, slug, name, description, descriptionHtml, priceMinor, currency, compareAtMinor, imageUrl, images, imageAlt, inventory, hasVariants, priceFromMinor, sku, tags, createdAt, url, vendor, productType, barcode, dimensions, unitPrice (description is the text form, descriptionHtml the sanitized markup; the merchant's cost price is never returned). imageUrl and images are absolute https:// URLs served from the store's hostname. Variant: id, title, values, sku, priceMinor, compareAtMinor, inventory, imageUrl. Unknown slugs and handles answer 404 { error }.

Paging. By default a listing returns the whole catalog in one response; a store with a few hundred products is fine, so cache it on your side and filter locally. For a larger store add ?limit=<1–100> and ?offset=<n> to /products or /collections/<handle>: the response then also carries total, limit, offset and nextOffset (null on the last page). offset alone means limit=100. Out-of-range values are 400 { error }.

Order lookup for the thank-you page. GET /api/storefront/orders?session_id=<the id the checkout page put in your successUrl> answers:

{
  "order": {
    "number": 1042, "status": "paid", "fulfillmentStatus": "unfulfilled",
    "currency": "USD", "subtotalMinor": 3300, "shippingMinor": 500, "discountMinor": 300, "taxMinor": 0, "totalMinor": 3500,
    "items": [{ "name": "Oat Granola", "variant": "", "quantity": 2, "unitPriceMinor": 1200, "imageUrl": "https://…" }],
    "emailMasked": "s***r@example.com", "trackingUrl": null, "createdAt": "2026-09-15T10:00:00.000Z"
  }
}

status is pending, paid, payment_failed or refunded; fulfillmentStatus is unfulfilled, fulfilled or cancelled; trackingUrl becomes the buyer's tracking page once the merchant adds a tracking number, so the same call works for an "order status" page later. The view is deliberately thin: no address, phone or full email. Orders paid by bank transfer or cash on delivery have no checkout session; look them up with ?order_id=<orderId from the checkout response>&email=<the buyer's email, exact>. Unknown → 404 { error }; an id that is not a cs_… session id, or missing parameters → 400.

4. Checkout from your site

POST https://formahand.com/api/checkout, Content-Type: application/json, from a page whose origin is on your allowed list:

{
  "storeId": "<store id>",
  "items": [
    { "productSlug": "linen-shirt", "quantity": 1, "variantId": "<variant id>" },
    { "productSlug": "tote-bag", "quantity": 2 }
  ],
  "successUrl": "https://shop.example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
  "cancelUrl": "https://shop.example.com/cart",
  "destinationCountry": "US",
  "destinationPostalCode": "94110",
  "mode": "live"
}
  • items: 1–20 lines of { productSlug, quantity (1–10), variantId? }; duplicate lines are merged. variantId is required for a product with options (hasVariants: true) and comes from the product endpoint's variants[].id. A single product may also be sent as { storeId, productSlug, quantity?, variantId? }.
  • successUrl / cancelUrl: optional; each must be an absolute URL on one of your allowed origins. {CHECKOUT_SESSION_ID} in successUrl is replaced by the checkout page.
  • destinationCountry / destinationPostalCode: optional; a store on live courier rates quotes shipping for that destination before the payment page opens. Flat-rate stores ignore them.
  • mode: optional; "test" runs the checkout against the store's sandbox (section 7). Anything but "live", "test" or blank is a 400.
  • embedded: optional; true returns what you need to mount the payment form on your own page instead of a URL (section 4b). hosted: true always returns the hosted { url }.
  • discountCode: optional; a code the shopper typed in your cart. The store checks it again against these lines, exactly as its own cart does, and prices the checkout with it when it applies; a code that does not apply is simply left off, and the checkout goes ahead at the price without it. Codes made from a template and codes with a per-customer limit need to know whose checkout it is: send email as well.
  • email: optional; the buyer's address. It is only ever counted against a code's per-customer limit and checked against a code bound to one address; it never prices anything on its own.
  • customerName: optional, up to 120 characters. Full Unicode name; it is not split into Western first/last-name assumptions.
  • shippingAddress: optional { name?, phone?, line1, line2?, city?, state?, postalCode?, country }. name defaults to customerName. Country is an assigned ISO alpha-2 code; local address requirements come from the bundled Google address metadata. Countries without postal codes do not need fake placeholders. Limits: name 120, phone 40, lines 200 each, locality/region 100 each, postal code 20. Malformed supplied details are rejected rather than discarded. The address, not conflicting legacy destination fields, determines destination-based rules.

Full-address handoff (2026-10-17). Card checkout creates a fresh, checkout-scoped customer on the merchant's connected account with the supplied shipping details and uses that customer for payment-page prefill. It never looks up or updates an existing customer by an unverified email. The payment provider confirms the final shipping details; the existing paid webhook records those on the order. PayPal receives the supplied shipping address and uses SET_PROVIDED_ADDRESS. Manual/bank-transfer checkout currently refuses the new name/full-address path rather than accepting and losing details; older callers without these fields are unchanged. Successful full-address requests carry X-Formahand-Address-Accepted: 1, including registered-domain redirects; errors never carry it. Addresses are private request-body/provider data, not query parameters, line notes or response metadata.

Supporting country formats does not enable delivery: the supplied country must have an actual configured shipping rate, including after checkout rules/functions. An unsupported destination returns 409 before a payment customer/session is created. No prices, rates or free-delivery eligibility can be supplied by the browser.

  • giftCardCode: optional; a gift card the shopper attached. It pays part of the order on the payment page.

Responses (always with CORS headers for your origin):

StatusBodyMeaning
200{ url }The hosted checkout page (Formahand Payments or PayPal); navigate there
200{ mode: "embedded", clientSecret, publishableKey, connectedAccountId, sessionId }Only when you sent embedded: true: mount the payment form on your own page (section 4b)
200{ instructionsUrl, orderId }The store takes bank transfer or cash on delivery: send the buyer to instructionsUrl
400{ error }Bad items, quantity, or a return URL not on an allowed origin
403{ error }Your origin is not on the store's list (also the answer to the OPTIONS preflight)
409{ error }Store not ready for payments, out of stock, mixed currencies, or a variant product sent without variantId
502 / 503{ error }The payment processor or the store did not answer

Tax and shipping are chosen on the payment page. A discount code comes in with the checkout call (discountCode above), and every automatic discount, customer-group price and store function the store runs is applied there too, so your front end never prices a cart itself. Stock is reserved for the session and the order is recorded when payment is confirmed. The buyer's confirmation email, and every email after it, comes from the store as usual.

4b. The payment form on your own page

Add "embedded": true to the same request and you get, instead of a URL to send the browser to, everything you need to mount the payment form on a page of your own. The form itself runs inside the payment provider's frame, so card details never reach your site and you stay out of PCI scope; the page around it is yours.

{
  "storeId": "<store id>",
  "items": [{ "productSlug": "linen-shirt", "quantity": 1 }],
  "successUrl": "https://shop.example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
  "embedded": true
}
{
  "mode": "embedded",
  "clientSecret": "cs_live_…_secret_…",
  "publishableKey": "pk_live_…",
  "connectedAccountId": "acct_…",
  "sessionId": "cs_live_…"
}

Mount it with Stripe.js, loaded from js.stripe.com (their terms require it; never bundle or self-host it):

<div id="payment"></div>
<script src="https://js.stripe.com/v3/"></script>
<script type="module">
  const res = await fetch("https://formahand.com/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      storeId: STORE_ID,
      items,
      successUrl: `${location.origin}/thanks.html?session_id={CHECKOUT_SESSION_ID}`,
      embedded: true,
    }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error);
  if (body.mode !== "embedded") {
    // The store turned the form off, or this checkout cannot be embedded.
    location.assign(body.url || body.instructionsUrl);
  } else {
    const stripe = Stripe(body.publishableKey, { stripeAccount: body.connectedAccountId });
    const checkout = await stripe.initEmbeddedCheckout({ clientSecret: body.clientSecret });
    checkout.mount("#payment");
  }
</script>
  • embedded is opt-in. Without it the answer is { url }, so nothing you already built changes.
  • successUrl becomes the form's return URL: after paying, the buyer lands on your page with session_id filled in, and GET /api/storefront/orders?session_id=… (section 3) shows them the order. Leave it out and they land on the store's own thank-you page.
  • clientSecret is single-use and bound to that one session. Fetch it when the buyer is ready to pay, never render it into a cached page, and never put it in a URL's query string — a fragment (#) is fine, a query is not.
  • connectedAccountId must be passed to Stripe() as stripeAccount, or the client secret will not be recognized: the payment is a direct charge on the merchant's account.
  • Always handle the non-embedded answer. A store that sells by bank transfer replies { instructionsUrl, orderId }, a store with the form switched off replies { url }, and { "hosted": true } in your request forces { url } whenever you need the hosted page (a payment method that cannot be embedded, a debugging session, a link you email).
  • Everything else is identical: the same pricing, the same stock reservation, the same discounts, the same webhooks, the same mode: "test" sandbox.

Avoid putting the form inside another iframe: some payment methods redirect to the bank's own page to confirm.

5. formahand.js, a tiny client

No build step, no dependency. Save it next to your page or paste it into a <script type="module">.

// formahand.js — the whole client for a custom front end.
export function formahand({ store, storeId }) {
  // store: the store's hostname, e.g. "myshop.store-on-formahand.app" or "shop.example.com"
  const base = `https://${store}/api/storefront`;
  const get = async (path) => {
    const res = await fetch(`${base}${path}`);
    const body = await res.json();
    if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
    return body;
  };
  return {
    products: ({ collection, q, sort } = {}) => {
      const params = new URLSearchParams();
      if (collection) params.set("collection", collection);
      if (q) params.set("q", q);
      if (sort) params.set("sort", sort);
      const query = params.toString();
      return get(`/products${query ? `?${query}` : ""}`).then((b) => b.products);
    },
    product: (slug) => get(`/products/${encodeURIComponent(slug)}`).then((b) => b.product),
    collections: () => get("/collections").then((b) => b.collections),
    collection: (handle) => get(`/collections/${encodeURIComponent(handle)}`).then((b) => b.collection),
    pages: () => get("/pages").then((b) => b.pages),
    page: (slug) => get(`/pages/${encodeURIComponent(slug)}`).then((b) => b.page),
    navigation: () => get("/navigation"),
    // The thank-you page: the session id the checkout page put in successUrl.
    order: (sessionId) => get(`/orders?session_id=${encodeURIComponent(sessionId)}`).then((b) => b.order),
    subscribe: async (email, name) => {
      const res = await fetch(`https://${store}/api/newsletter`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, name }),
      });
      const body = await res.json();
      if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
    },
    price: (minor, currency) =>
      new Intl.NumberFormat(undefined, { style: "currency", currency }).format(minor / 100),
    // items: [{ productSlug, quantity, variantId? }]; mode: "test" for the sandbox
    checkout: async (items, { successUrl, cancelUrl, destination, mode } = {}) => {
      const res = await fetch("https://formahand.com/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          storeId, items, successUrl, cancelUrl, mode,
          ...(destination ? { destinationCountry: destination.country, destinationPostalCode: destination.postalCode } : {}),
        }),
      });
      const body = await res.json();
      if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
      location.assign(body.url || body.instructionsUrl);
    },
  };
}

For the payment form on your own page, add embedded: true to that checkout() body and mount the answer as in section 4b instead of navigating.

price() divides by 100, which is right for USD, EUR, GBP and most currencies; use minor as is for zero-decimal currencies such as JPY.

Plain HTML

<div id="products"></div>
<script type="module">
  import { formahand } from "./formahand.js";
  const fh = formahand({ store: "myshop.store-on-formahand.app", storeId: "<store id>" });
  const list = document.getElementById("products");
  for (const p of await fh.products({ sort: "newest" })) {
    const card = document.createElement("article");
    card.innerHTML = `<img src="${p.imageUrl}" alt=""><h3></h3><p></p><button>Buy</button>`;
    card.querySelector("h3").textContent = p.name;
    card.querySelector("p").textContent = fh.price(p.priceMinor, p.currency);
    card.querySelector("button").onclick = () =>
      fh.checkout([{ productSlug: p.slug, quantity: 1 }], {
        successUrl: `${location.origin}/thanks.html?session_id={CHECKOUT_SESSION_ID}`,
        cancelUrl: location.href,
      }).catch((e) => alert(e.message));
    list.append(card);
  }
</script>

A product with hasVariants: true needs a picker: call fh.product(slug), render options as selects, find the variant whose values match, and pass its id as variantId.

The thank-you page (thanks.html):

<h1>Thank you</h1>
<p id="summary"></p>
<script type="module">
  import { formahand } from "./formahand.js";
  const fh = formahand({ store: "myshop.store-on-formahand.app", storeId: "<store id>" });
  const sessionId = new URLSearchParams(location.search).get("session_id");
  const order = await fh.order(sessionId).catch(() => null);
  document.getElementById("summary").textContent = order
    ? `Order #${order.number}: ${order.items.map((i) => `${i.quantity} × ${i.name}`).join(", ")} — ${fh.price(order.totalMinor, order.currency)}. A confirmation went to ${order.emailMasked}.`
    : "Your order is confirmed; the confirmation email is on its way.";
</script>

Next.js (App Router)

// app/page.tsx — server component: the catalog is fetched at build or request time.
const STORE = "myshop.store-on-formahand.app";

export default async function Shop() {
  const res = await fetch(`https://${STORE}/api/storefront/products`, { next: { revalidate: 60 } });
  const { products } = await res.json();
  return (
    <main>
      {products.map((p: any) => (
        <article key={p.id}>
          <img src={p.imageUrl} alt={p.imageAlt} />
          <h3>{p.name}</h3>
          <BuyButton slug={p.slug} />
        </article>
      ))}
    </main>
  );
}
// app/buy-button.tsx — client component: checkout must start from the browser on your allowed origin.
"use client";
export function BuyButton({ slug }: { slug: string }) {
  async function buy() {
    const res = await fetch("https://formahand.com/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        storeId: process.env.NEXT_PUBLIC_FORMAHAND_STORE_ID,
        items: [{ productSlug: slug, quantity: 1 }],
        successUrl: `${location.origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
        cancelUrl: location.href,
      }),
    });
    const body = await res.json();
    if (!res.ok) return alert(body.error);
    location.assign(body.url ?? body.instructionsUrl);
  }
  return <button onClick={buy}>Buy</button>;
}

Add both https://shop.example.com and http://localhost:3000 to Custom front ends so next dev works too.

5b. Show prices in the shopper's currency

One script tag turns any page's prices into the shopper's own currency on a site you built:

<script src="https://formahand.com/js/currency.js"
        data-base="USD"                    <!-- the store currency (what checkout charges) -->
        data-currencies="USD,EUR,GBP,VND"  <!-- the choices offered -->
        data-picker="#currency"></script>  <!-- optional <select> it fills and listens to -->

<span data-price="2400" data-currency="USD"></span>   <!-- minor units, as the API returns them -->
<select id="currency" aria-label="Currency"></select>

Every [data-price] element is formatted with Intl.NumberFormat in the current currency (guessed from the browser locale, remembered in localStorage, changed with the picker or FormahandCurrency.set("EUR")), and again whenever new prices appear on the page. Converted figures carry data-estimate="true" so you can mark them (the demo adds an "≈"). Rates come from GET https://formahand.com/api/rates?base=USD (daily reference rates, cached an hour in the browser, public, CORS open, { base, date, rates }; 503 when unavailable, in which case prices simply show in the store currency).

Not on the hosted storefront. A Formahand-hosted storefront takes no script tag of yours at all: custom HTML blocks drop <script>, embeds are a fixed provider list, and since 2026-09-23 every storefront response carries a Content-Security-Policy whose script-src is a per-response nonce and whose connect-src is 'self' (custom-code.md §4) — so neither the tag nor its call to /api/rates would run. The endpoint stays public and CORS-open for your own front end.

This is display only. Checkout charges the store currency; the payment page may show its own localised amount. Never send a converted price back to Formahand. FormahandCurrency.format(minor, "USD"), .convert(minor, from, to), .current, .ready (a promise) and the formahand:currency window event are there for your own code.

6. Plugins and bots

A plugin acts on the store with a token, and hears about the store through events.

Tokens and scopes. Settings → Agents & API mints a token (fh_<mode>_… for the real store, fh_test_… for the sandbox). Send it as Authorization: Bearer <token> to the same /api/* routes the dashboard uses. Give a plugin only the scopes it needs:

ScopeLets a plugin
products:read / products:writeread and change products, images, collections (/api/products, /api/collections, /api/product-images)
orders:read / orders:writelist and read orders (GET /api/orders, ?orderId= for one, ?format=csv for a spreadsheet), fulfil, add tracking, buy labels
settings:read / settings:writeshipping, discounts, allowed origins (/api/checkout-origins), subscriptions, flows, email templates
events:readread the event log (GET /api/events, /api/events/:id, /api/events/vocabulary)

Tokens are bound to one store and one environment, never billed, and rate limited to 10 requests a second (bursts of 30; 429 with Retry-After). Minting tokens, team changes, payment onboarding and customer export are dashboard-only.

MCP endpoint. https://formahand.com/mcp (Streamable HTTP, bearer token). Every tool in agents.md is available to a plugin that speaks MCP, and to Claude Code or Codex:

claude mcp add formahand --transport http https://formahand.com/mcp --header "Authorization: Bearer <token>"

Events and subscriptions. Every change to the store is an event named <resource>.<verb> (order.created, product.updated, customer.created, …) plus aliases such as order.paid, order.fulfilled, order.delivered, cart.abandoned. A subscription picks topics (with * per segment), an optional filter, and a target: your webhook URL, or an "agent" endpoint that receives a CloudEvents batch with an optional bearer. Create one in Automations → Webhooks (#automations/subscriptions/new), with POST /api/subscriptions, or with the MCP tool:

create_subscription {
  topics: ["order.paid"],
  target: { kind: "webhook", url: "https://bot.example.com/formahand" }
}
test_subscription { id }

Each delivery is a CloudEvents JSON envelope (type, time, mode, data.object, data.changed, data.previous) signed per Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature: v1,<base64 HMAC-SHA256(secret, "<id>.<timestamp>.<body>")>. The secret is shown on the subscription. Answer 2xx; failures retry at 1 min, 5 min, 30 min, 2 h and 8 h, and a subscription is paused after 20 consecutive failures. Use webhook-id as the idempotency key. Verification code and the full grammar are in events.md.

A Discord bot, for example: subscribe to order.paid, verify the signature, post data.object.orderNumber, totalMinor and the items to a channel, and use orders:read when someone asks "where is order 1042".

No webhook receiver? Flows do the common things inside the store (send an email from a template, tag the order, notify the merchant, call a URL, wake an agent): see automations.md.

7. Test first: the sandbox

Every store can have a Test environment: a second copy of the store at <slug>-test.store-on-formahand.app with test payments, free test labels and email delivered only to the store owner. Enable it in the dashboard (the environment switch) or with enable_sandbox, then:

  • The read API works on the sandbox hostname: point your development build at https://<slug>-test.store-on-formahand.app/api/storefront/products.
  • Mint an fh_test_ token for your plugin; it reaches only the sandbox, and events it receives carry "mode": "test" in the envelope.
  • Place test orders on the sandbox's hosted storefront with test cards (4242 4242 4242 4242) to exercise your subscriptions end to end.
  • When it works, promote_to_live copies catalog and settings to the live store.
  • Send "mode": "test" in the checkout body (from an allowed origin) and the checkout runs in the sandbox: its catalog and stock, test payment account and the processor's test cards, successUrl on your site as usual. The order appears on the sandbox's dashboard and GET https://<slug>-test.store-on-formahand.app/api/storefront/orders?session_id=… shows it. Without mode the same call reaches the live store, so keep the flag behind your own development setting. The allowed-origin list is shared by both environments.

8. Ask your agent

Paste this into Claude Code or Codex that has the Formahand MCP server connected (the claude mcp add line above, with a token that has products:read, settings:read and settings:write):

Build me a custom storefront for my Formahand store.

1. Call describe_system, then get_store. Note the store id and the primary hostname.
2. Call list_products and list_collections to see what I sell.
3. Read https://formahand.com/docs/headless. Use only the endpoints it lists:
   - catalog: GET https://<hostname>/api/storefront/products, /products/<slug>, /collections, /collections/<handle>
   - content: GET https://<hostname>/api/storefront/pages, /pages/<slug>, /navigation
   - checkout: POST https://formahand.com/api/checkout with { storeId, items, successUrl, cancelUrl }; navigate to the returned url
   - thank-you page: GET https://<hostname>/api/storefront/orders?session_id=<from the successUrl>
   - newsletter: POST https://<hostname>/api/newsletter with JSON { email }
4. Build the site as <Next.js | a static site | …> in this folder: a home page with the products, a product page with a variant picker
   when hasVariants is true, the store's content pages and navigation, a cart kept in localStorage, a Buy button that calls the
   checkout endpoint, and a /thanks page that shows the order. Never send prices to the checkout endpoint; Formahand prices the items.
5. Call get_checkout_origins, then set_checkout_origins with the existing origins plus http://localhost:3000 and my production
   origin <https://shop.example.com>, so the checkout call is allowed from both.
6. Tell me how to run it locally and what to deploy.

8b. Shopper accounts on your own site

Your shoppers can sign in to their account from your front end, with no password, and read back their own orders. The full behaviour is in Shopper accounts; this is the JSON half.

const store = "https://shop.example.com";

// 1. Ask for a code. The answer is the same whether or not the address is
//    known to the store: never use it to check whether someone has an account.
await fetch(`${store}/api/storefront/account/login`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email }),
});

// 2. The shopper types the six-digit code from their email.
const verified = await fetch(`${store}/api/storefront/account/verify`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, code }),
}).then((response) => response.json());
// -> { token, expiresAt, customer: { email, name, phone, marketingStatus, address } }

// 3. Keep `token` and send it on every later call.
const auth = { Authorization: `Bearer ${verified.token}` };
const account = await fetch(`${store}/api/storefront/account`, { headers: auth })
  .then((response) => response.json());
// -> { customer, orders: [{ id, number, status, fulfillmentStatus, currency,
//      totalMinor, createdAt, trackingUrl, url, downloads: [{ name, url }] }] }
CallBodyAnswers
POST /api/storefront/account/login{ email }{ ok: true, message }, always the same
POST /api/storefront/account/verify{ email, code }{ token, expiresAt, customer }
GET /api/storefront/account—{ customer, orders }
GET /api/storefront/account/orders/<id>—{ order } with items and downloads
PUT /api/storefront/account/profile{ name?, phone?, marketing? }{ customer }
GET / PUT /api/storefront/account/addresses{ name?, line1?, line2?, city?, state?, postalCode?, country? }{ address }
POST /api/storefront/account/logout—{ ok: true }

Notes that matter:

  • The bearer token is the shopper. These routes answer any origin (*) like the rest of the read API, and never with credentials, so the cookie the store's own pages use is never sent from your site. Keep the token where a session lives on your front end, send it as Authorization: Bearer <token>, and drop it on logout. It is good for 30 days.
  • marketing takes "subscribe" or "unsubscribe" and writes that shopper's own consent, nothing else.
  • Prefill checkout. Pass the signed-in shopper's address as email in the POST https://formahand.com/api/checkout body and the payment page opens with it filled in.
  • A signed-in shopper only ever reads their own orders; an order id that belongs to someone else answers 404, not the order.

9. Limits and errors

LimitValue
Allowed front-end origins per store10; https://host[:port] only, http://localhost / http://127.0.0.1 for development
Checkout linesup to 20 products, quantity 1–10 each; one currency per checkout
Read APIwhole catalog by default; limit 1–100 with offset when paging; search 80 characters; cached 60 s (order lookup: never)
Token rate limit10 requests/s, bursts of 30, on /api/* and /mcp
Subscriptions20 per store, 1–20 topics each, filter 1000 characters; 20 failures in a row pause it
Event dataobjects trimmed to 32 KB (long arrays and strings cut); events kept 90 days
Return URLsabsolute, on an allowed origin, 2048 characters
ErrorWhereWhat to do
403 This origin is not allowed to start checkout.checkoutAdd the page's exact origin (scheme, host, port) to Custom front ends
400 successUrl must be an https URL on one of this store's allowed front-end origins.checkoutSame list; the return URL host must match one of them
409 This store is not ready to accept online payments.checkoutFinish payments in Payments (Formahand Payments onboarding, PayPal keys, or manual instructions)
409 out of stock / "choose an option"checkoutRe-read the product; pass variantId for products with options
404 { error }read APIUnknown slug, collection handle, page, or order (wrong session id, or order_id + email that do not match)
400 { error }read APIlimit / offset out of range, or an order lookup without a cs_… session id (or without order_id + email)
400 { error: "Enter a valid email address." }newsletterBad or missing email in the JSON body
401 / 403token routesMissing token, or a token without that route's scope
402 / 409 { wall: true, … }token routesA plan or setup wall; steps[] says what to do (billing.md)
429 with Retry-Aftertoken routesBack off for that many seconds

Coming

Not built yet; do not code against these.

  • Cart quote before checkout (shipping and discount totals for a cart, and whether a code applies, before the buyer leaves your site): today the checkout call applies the code and the payment page shows the totals. A store's own storefront has /__fh/cart.json for this, same-origin only and never for another site; it is not an API for a front end on another address.
  • Per-environment origin lists. The allowed front-end origins are one list for the live store and its sandbox.
  • Per-origin newsletter CORS. POST /api/newsletter answers any origin (*), like the read API; it is not restricted to the allowed checkout origins.
  • Your own payment fields. The payment form can be mounted on your page (section 4b), but it is the provider's form inside the provider's frame; building the card inputs yourself is not offered, and that is what keeps you out of PCI scope.

Product kinds

Products carry kind (physical | digital | service), backorder ({ allowed, promisedAt?, message? }) and digital: { files: <count> } in both GET /api/storefront/products and GET /api/storefront/products/<slug> (product-kinds.md). kind is physical for every product a store has not changed, so a front end written before this existed keeps working.

Use them like this:

  • Hide your shipping copy and your address step when every line in the cart has requiresShipping === false — that is, when no line is physical. Formahand does the same on its side: a digital-or-service-only checkout is created with no shipping address and no rates, so your POST /api/checkout call needs nothing extra.
  • Show backorder.message (or "ships by backorder.promisedAt") on a product you let shoppers buy at zero stock; inventory: 0 with backorder.allowed is buyable, not sold out.
  • On your thank-you page, GET /api/storefront/orders?session_id=… now also answers downloads: [{ name, url, expiresAt }] for a paid digital order. Render them as links; they work for 72 hours from the moment you read them, and reading the endpoint again issues a fresh set, so you never have to cache one.

Content and SEO

Two more public endpoints, same rules as the rest of the read API: cached, CORS-open, no token, published rows only (content-seo.md).

EndpointAnswers
GET /api/storefront/posts{ posts: [{ slug, title, excerpt, coverImageUrl, author, tags, publishedAt, updatedAt, url }] }, newest first
GET /api/storefront/posts/<slug>{ post: { …, bodyHtml } } — the Markdown already rendered, the same HTML /api/storefront/pages/<slug> returns

A draft is a 404, exactly like an unknown slug, so nothing unpublished can leak into a front end of your own.

The store also serves /blog, /blog/<slug> and /blog/feed.xml (RSS 2.0) in its own theme, and lists the posts in /sitemap.xml. If your front end owns /blog, link the feed from your own pages and leave the store's /blog unlinked; if it does not, point readers at the store's and save yourself the work.

One more thing that matters to a front end you host yourself: the store keeps a redirect table, and a renamed product, collection, page or post writes its own 301. GET /api/redirects (with a token, storefront:read) hands you the list so your own router can follow the same moves.

Feeds and the agent-readable store

Whatever front end you build, the store keeps publishing itself for shopping engines and AI agents on its own hostname (feeds.md): /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 — and /llms.txt plus /.well-known/formahand-store.json describe the store, its currency, its shipping countries, this read API and how to check out. All five are public, cached ten minutes and need no token; the feeds are noindex and stay out of the sitemap.

Two things worth doing when the pages are yours:

  • The link in every feed row points at the store's own product page. If your site owns the product URLs, either keep the store's pages reachable (they are, unless you point the domain elsewhere) or submit your own feed built from /feeds/products.json with the links rewritten — the JSON feed exists for exactly that.
  • Copy the head tag onto your pages: <link rel="alternate" type="application/rss+xml" href="https://<your store>/feeds/google.xml">. A crawler that lands on your page then finds the catalog. The store id an agent needs for POST /api/checkout is in /.well-known/formahand-store.json and in /llms.txt, which is where an agent looks for it; the storefront used to print it in a formahand-store meta tag on every page, which nothing read.

A store hidden from search engines (Settings → SEO) answers 404 on all five addresses.

See also

  • feeds.md — the shopping feeds, the field mapping and the store's own llms.txt.
  • agents.md — the token scopes, the MCP tool catalogue and the parity rule behind everything here.
  • product-kinds.md — a digital or service cart checks out with no address and no shipping rate; downloads reach the buyer through the store's own hostname.
  • custom-fields.md — the extra data your front end can read back on a product, a line or a customer.
  • checkout-rules.md — automatic discounts and group prices are applied at checkout, so your front end never prices a cart itself.
  • content-seo.md — posts, per-page titles and the redirect table your own router should follow.
  • shopper-accounts.md — passwordless sign-in for the shoppers on your own site.
  • analytics.md — what is still counted when the front end is yours, and what is not.