Metaobjects — merchant-defined content types
Merchant-defined structured content (size charts, brand profiles, FAQ blocks) served by TYPE + HANDLE (metaobjects card). ACTIVE entries only — drafts 404 (lib filter AND anon RLS, defense in depth).
Capabilities per definition:
renderable→ payload gainsseo {title, description}(title falls back todisplayName, description to null);online_store→ payload gainsurl(/metaobjects/<type>/<handle>— the storefront prepends its origin).
References resolve at read time: a target the storefront cannot see
(deleted, or draft under anon RLS) yields reference: null — never
dropped, never a 500. by-metafield is a reserved type slug (the
static route shadows /:type; rejected at definition create).
Metaobject shape (all endpoints):
{
"id": "uuid",
"type": "size-chart",
"handle": "shirts-eu",
"displayName": "Shirts (EU)",
"fields": [
{ "key": "body", "kind": "rich_text", "value": "<table>…</table>" },
{ "key": "product", "kind": "product_reference", "value": "prod_…",
"reference": { "id": "prod_…", "title": "Linen Shirt", "handle": "linen-shirt" } }
],
"seo": { "title": "Shirts (EU)", "description": null }, // renderable only
"url": "/metaobjects/size-chart/shirts-eu", // online_store only
"updatedAt": "ISO-8601"
}Definitions + entries are admin-authored; the shared dev tenant seeds none, so the executable blocks prove the 404/400 contracts and the happy paths are pinned by
tests/store/metaobjects-store.test.ts(admin-auth fixtures).
GET /api/store/metaobjects/:type — list entries
- Purpose: list ACTIVE entries of one type (e.g. all FAQ blocks), newest-updated first.
- Auth: anon (
x-client-id). - Request: query
{limit?, offset?}—limitclamped to 1–100 (default 20; out-of-range values are clamped, not rejected). - Response:
{metaobjects: [Metaobject…], count, offset, limit}— list payloads carry raw field values (referenceNOT resolved; the by-handle read is the resolve hot path).seo/urlcapabilities still apply. - Errors:
404 not_found(unknown type). - SDK:
metaobjects.listMetaobjects(client, type, query?) - Components: FAQ/content-block section renderers.
- Settings: definitions + entry status in admin → Content → Metaobjects.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID" \
| grep -q '"code":"not_found"'GET /api/store/metaobjects/:type/:handle — one entry
- Purpose: render one entry — references RESOLVED (invisible targets →
reference: null). - Auth: anon (
x-client-id). - Response:
{metaobject: Metaobject}(shape above, references resolved). - Errors:
404 not_found— unknown type, unknown handle, or a DRAFT entry (drafts are indistinguishable from missing, by design). - SDK:
metaobjects.getMetaobject(client, type, handle) - Components: size-chart modal, brand-profile block.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/metaobjects/doc-no-such-type-$RUN/doc-h-$RUN" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404GET /api/store/metaobjects/by-metafield — the product→chart chain
- Purpose: the canonical fetch chain — resolve an entity's
metaobject_referenceMETAFIELD into full metaobject payload(s) in ONE call: storefront reads the product (id in hand) → this call with the metafield key → the resolved chart. - Auth: anon (
x-client-id). Leak boundary (deliberate): onlymetaobject_referencedefinitions resolve here — any other metafield key 404s, so no other metafield value can exit through this route; entries return ACTIVE-only. - Request: query
{entity_type, entity_id, key}— all three required. - Response:
metaobjectsalways present, in stored order.metaobject(first entry or null) is present only for single-valued definitions — LIST definitions return{metaobjects}alone:
// single-valued definition
{ "metaobject": { /* Metaobject */ }, "metaobjects": [ /* [it] */ ] }
// list definition
{ "metaobjects": [ /* Metaobject[] in stored order */ ] }- Errors:
400 invalid_data(missing entity_type/entity_id/key) ·404 not_found(no such definition, wrong field_type, or no stored value for the entity). - SDK:
metaobjects.getMetaobjectsByMetafield(client, query) - Components: PDP size-chart trigger; any metafield-driven block.
- Settings: the metafield definition (entity type + key +
metaobject_referencefield type) in admin → Settings → Metafields.
# Missing params → 400 invalid_data.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/metaobjects/by-metafield?entity_type=product" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400
# Unknown key on a real seeded product → 404 (the leak-boundary contract).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/metaobjects/by-metafield?entity_type=product&entity_id=prod_01tst00000000000000000001&key=doc-nokey-$RUN" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404