Custom fields

Typed, namespaced fields on products, variants, collections, orders, order lines and customers: types, limits, the API, the agent tools and events.

Written 2026-09-16. Typed, namespaced values you can put on any object in a store — a product, a variant, a collection, an order, an order line or a customer — for everything Formahand never designed a box for: a supplier order id, a cost price, a print file link, a wholesale tier, a warehouse bin, a licence key. The dashboard shows them, tokens and agents read and write them, they travel in the store's event log (events.md), and promote and refresh carry the catalog's along (environments.md).

Nothing here is rendered on your storefront. Custom fields are your own data about an object, not part of what shoppers see.

1. The shape of a field

A field is addressed by four things:

PartWhat it is
ownerTypeproduct, variant, collection, order, order_line or customer
ownerIdthat object's id
namespaceyour grouping, ^[a-z][a-z0-9_]{1,30}$ — ops, wholesale, print
keythe field itself, ^[a-z][a-z0-9_]{1,60}$ — supplier_order_id

namespace plus key is unique per object: writing the same pair twice replaces the value, it never makes a second field. Namespaces exist so two systems (your ERP and your print shop, say) can both keep fields on the same product without colliding — pick one namespace per system or per concern and stay in it.

2. The seven types

TypeValueLimit
texta string2000 characters
numbera finite number, whole or decimal—
money{ amountMinor, currency }amountMinor is a whole number of minor units, currency a three-letter code
date"2026-10-01", or a full ISO 8601 timestampmust be a real date
booleantrue or false—
jsonany JSON value8 KB serialized, nested at most 5 levels deep
filean https URL2048 characters

A file field holds a link, not the bytes: upload the file first (upload_image returns an https URL on your own store hostname for images) or link to wherever it already lives.

3. Limits

  • 50 fields per object and 50 per call.
  • 20,000 fields per store.
  • Over either ceiling the answer is 409 with the usual wall body: { "error": "…", "wall": true, "feature": "customFields", "reason": "limit", "usage": { "used": 50, "limit": 50 } } (agents.md § Walls). Remove a field before adding another; nothing is written when a call is refused.
  • A bad namespace, key, type or value is 400 with the reason, the field that is wrong and its position in the list you sent.

4. The API

/api/custom-fields on the platform, same as every other route: a browser session, or Authorization: Bearer fh_<mode>_… / fh_test_… for the environment the token names.

CallBody or queryAnswers
GET /api/custom-fields?ownerType=&ownerId=—{ fields: [{ ownerType, ownerId, namespace, key, type, value }] }, ordered by namespace then key
PUT /api/custom-fields{ ownerType, ownerId, fields: [{ namespace, key, type, value }] }the object's complete field list after the write
DELETE /api/custom-fields{ ownerType, ownerId, namespace, key }{ success: true }, or 404 when there is no such field

PUT merges: the fields you send are created or replaced, every other field on that object is left alone. To clear one, delete it.

Scopes: products:read for GET, products:write for PUT and DELETE. Fields on an order, an order_line or a customer are buyer data, so they additionally need orders:read / orders:write; a token without them gets a 403 that names the scope.

curl -sS -X PUT https://formahand.com/api/custom-fields \
  -H "Authorization: Bearer fh_<mode>_…" -H "Content-Type: application/json" \
  -d '{"ownerType":"product","ownerId":"<product id>",
       "fields":[{"namespace":"ops","key":"supplier_order_id","type":"text","value":"PO-99214"},
                 {"namespace":"ops","key":"cost_price","type":"money",
                  "value":{"amountMinor":640,"currency":"USD"}}]}'

5. The agent tools

ToolScopeDoes
describe_custom_fieldsproducts:readthe owner types, the seven value types with shapes and limits, the namespace rules, merge semantics, scopes, how fields show up in views and events, and what promote copies. Read-only; call it first
list_custom_fieldsproducts:read{ ownerType, ownerId } → every field on that object
set_custom_fieldsproducts:write{ ownerType, ownerId, fields: [...] }, merging as above
delete_custom_fieldproducts:write{ ownerType, ownerId, namespace, key }

The three writing and reading tools take the orders scopes too for the order, order_line and customer owner types, exactly like the route.

6. Where fields show up

  • Read views. A product, an order or a customer read back through the API or a tool carries a fields array of { namespace, key, type, value } when it has any, and an order's lines carry their own. An object with no custom fields is unchanged, so nothing that worked before sees a new key.
  • Events. Every write records custom_field.created, custom_field.updated or custom_field.deleted in the store's log, with data { ownerType, ownerId, namespace, key, type, value } and the resource id <ownerType>:<ownerId>:<namespace>.<key>. Subscribe to custom_field.* like any other topic, or react inside the store with a flow (automations.md). Because product, order and customer payloads carry their fields, a product.updated event also shows the current custom fields.
  • The dashboard. Custom fields sit in a product's Organise part (#products/<id>/organise, while you are editing an existing product) and in the order detail: namespace.key, type and value, with add, edit and remove.

7. Promote and refresh

promote_to_live and refresh_sandbox copy the custom fields of products (matched by their URL handle), variants (matched by SKU, else by their option values) and collections (matched by handle), alongside the objects themselves. Fields on orders, order lines and customers are never copied, because orders and customers are never copied between environments. A field whose object could not be matched is counted as skipped in the summary's customFields line.

8. Good practice

  • One namespace per system. ops for your own back office, wholesale for trade pricing, print for production files — not one namespace per field.
  • Pick the narrow type. money and date are worth using over text: they validate, and anything reading your store later knows what it is holding.
  • Keep json small and flat. It is for a shape that genuinely has parts, not for a second database.
  • Keep secrets out. Custom fields are readable by every token with the object's read scope and they travel in the event log; credentials belong in an integration's secrets (modules-v2.md).

See also

  • agents.md — the scopes the four field tools need and where fields sit in the tool catalogue.
  • checkout-rules.md — a rule can read a field (customField), and supplier.cost is the floor a discount never crosses.
  • product-kinds.md — what the catalog already models, so you only reach for a field when it genuinely does not.
  • recipes.md — a recipe documents the fields it uses, so installing one tells you the namespace to keep.
  • headless.md — how fields come back to a front end you host yourself.