Skip to main content

Overview

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.
For structured, type-safe custom attributes with admin UI support, use Metafields instead.

Store API

Cart creation

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' }
})

Adding items

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!' },
})

Updating items

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!' },
})

Updating carts

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 })

Admin API

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.

Ruby / Backend

Reading and writing

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!

Querying

# 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'")

Merge semantics

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" }

Metadata vs Metafields

Spree has two permanent, complementary systems for custom data. They are not interchangeable and neither is going away.
MetadataMetafields
PurposeDeveloper escape hatch — integration data, sync state, ad-hoc flagsMerchant-defined structured attributes with admin UI
SchemaSchemaless JSON — no definition requiredDefined via MetafieldDefinitions (typed, validated)
ValidationNone — accepts any JSON-serializable dataType-specific (text, number, boolean, rich text, JSON)
VisibilityWrite-only in Store API, readable in Admin APIConfigurable (front-end, back-end, both)
Admin UIJSON preview onlyDedicated management forms
API patternStripe-style: metadata: { key: value }Expand-based: ?expand=metafields
QueryableVia JSONB operators (PostgreSQL)Via SQL joins, Ransack scopes, search providers

When to use metadata

  • Storing external system IDs (e.g., Stripe payment intent ID, ERP order ID)
  • Tracking attribution data (UTM parameters, referral source)
  • Passing context from the storefront that doesn’t need validation
  • Any write-and-forget data that only needs to be read by backend systems
  • Syncing state with external integrations (webhooks, ETL pipelines)

When to use metafields

  • Custom product specifications shown to customers (material, dimensions, certifications)
  • Admin-managed fields with validation and type safety
  • Data that needs to appear in the admin UI with dedicated form inputs
  • Querying/filtering by custom attributes (search facets, product filtering)
  • CSV import/export of structured product data

Supported resources

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:
  • Carts — on creation and update (POST /api/v3/store/carts, PATCH /api/v3/store/carts/:id)
  • Items — on create and update (POST/PATCH /api/v3/store/carts/:id/items)

Deprecation: public_metadata

public_metadata is deprecated and will be removed in Spree 6.0. Use metadata instead.
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!')