docs/developer/customization/metadata.mdx
Metadata provides simple, unstructured key-value storage on Spree resources — similar to Stripe's metadata. It's ideal for storing integration IDs, tracking data, or any arbitrary information that doesn't need validation or admin UI.
Metadata is a permanent, first-class system in Spree. It is designed to coexist alongside Metafields (structured, typed, admin-managed). The two systems serve different purposes and are not interchangeable — think of it as metadata for machines, metafields for humans.
Metadata is write-only in the Store API — you can set it when creating or updating resources, but it is never returned in Store API responses. It is visible in Admin API responses for administrative use.
<Info> For structured, type-safe custom attributes with admin UI support, use [Metafields](/developer/core-concepts/metafields) instead. </Info>Set metadata when creating a new cart:
POST /api/v3/store/carts
Content-Type: application/json
{
"metadata": {
"source": "mobile_app",
"campaign": "summer_sale"
}
}
// @spree/sdk
const cart = await client.carts.create({
metadata: { source: 'mobile_app', campaign: 'summer_sale' }
})
Set metadata when adding items to the cart:
POST /api/v3/store/carts/:cart_id/items
Content-Type: application/json
{
"variant_id": "variant_k5nR8xLq",
"quantity": 1,
"metadata": {
"gift_note": "Happy Birthday!",
"engraving": "J.D."
}
}
const order = await client.carts.items.create(cartId, {
variant_id: 'variant_k5nR8xLq',
quantity: 1,
metadata: { gift_note: 'Happy Birthday!' },
})
Metadata is merged with existing values on update. Set a key to null to remove it.
PATCH /api/v3/store/carts/:cart_id/items/:id
Content-Type: application/json
{
"metadata": {
"engraving": "A.B.",
"gift_note": null
}
}
You can update metadata without changing quantity, or update both at once:
// Metadata only
await client.carts.items.update(cartId, lineItemId, {
metadata: { engraving: 'A.B.' },
})
// Both quantity and metadata
await client.carts.items.update(cartId, lineItemId, {
quantity: 3,
metadata: { gift_note: 'Happy Birthday!' },
})
PATCH /api/v3/store/carts/:id
Content-Type: application/json
{
"metadata": {
"utm_source": "google",
"utm_campaign": "summer_sale"
}
}
await client.carts.update(cartId, {
metadata: { utm_source: 'google' },
}, { spreeToken })
Metadata is readable in Admin API responses on orders and line items:
{
"id": "or_m3Rp9wXz",
"number": "R123456",
"metadata": {
"source": "mobile_app",
"utm_campaign": "summer_sale"
},
"items": [
{
"id": "li_x8Kp2qWz",
"metadata": {
"gift_note": "Happy Birthday!"
}
}
]
}
When there is no metadata, the field is null.
Every model that includes Spree::Metadata has a metadata accessor. Always use metadata in Ruby code — do not call public_metadata= or private_metadata= directly. (SQL queries still reference the underlying private_metadata column name — see querying examples below.)
order = Spree::Order.find_by!(number: 'R123456')
# Write
order.metadata = { 'source' => 'mobile_app' }
order.save!
# Read
order.metadata['source'] # => "mobile_app"
# Merge
order.metadata = order.metadata.merge('campaign' => 'summer')
order.save!
# Find orders with specific metadata value (PostgreSQL)
Spree::Order.where("private_metadata->>'source' = ?", "mobile_app")
# Check for key existence
Spree::Order.where("private_metadata ? 'source'")
Metadata updates use merge semantics — existing keys are preserved, new keys are added, and keys set to null are removed. This matches Stripe's behavior.
# Initial metadata
{ "source": "mobile_app", "campaign": "summer" }
# Update with
{ "campaign": "winter", "new_key": "value" }
# Result
{ "source": "mobile_app", "campaign": "winter", "new_key": "value" }
Spree has two permanent, complementary systems for custom data. They are not interchangeable and neither is going away.
| Metadata | Metafields | |
|---|---|---|
| Purpose | Developer escape hatch — integration data, sync state, ad-hoc flags | Merchant-defined structured attributes with admin UI |
| Schema | Schemaless JSON — no definition required | Defined via MetafieldDefinitions (typed, validated) |
| Validation | None — accepts any JSON-serializable data | Type-specific (text, number, boolean, rich text, JSON) |
| Visibility | Write-only in Store API, readable in Admin API | Configurable (front-end, back-end, both) |
| Admin UI | JSON preview only | Dedicated management forms |
| API pattern | Stripe-style: metadata: { key: value } | Expand-based: ?expand=custom_fields |
| Queryable | Via JSONB operators (PostgreSQL) | Via SQL joins, Ransack scopes, search providers |
All models that include the Spree::Metadata concern support metadata. This includes all core models: Orders, Line Items, Products, Variants, Taxons, Payments, Shipments, and more.
The Store API currently supports writing metadata on:
POST /api/v3/store/carts, PATCH /api/v3/store/carts/:id)POST/PATCH /api/v3/store/carts/:id/items)The public_metadata column was never exposed in Store API responses and in practice served the same purpose as private_metadata. It will be removed in Spree 6.0 and calling public_metadata= will emit a deprecation warning.
Always use the single metadata accessor for all schemaless key-value storage. If you need data visible to customers on the storefront, use Metafields instead.
# Deprecated — will be removed in 6.0
order.public_metadata = { 'gift_message' => 'Happy Birthday!' }
# Use metadata for internal storage
order.metadata = { 'gift_message' => 'Happy Birthday!' }
# Use metafields for customer-visible structured data
order.set_metafield('custom.gift_message', 'Happy Birthday!')