---
title: Metadata
---

## Overview

Metadata provides simple, unstructured key-value storage on Spree resources — similar to [Stripe's metadata](https://docs.stripe.com/api/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 [Custom Fields](../core-concepts/metafields.md) (structured, typed, admin-managed). The two systems serve different purposes and are not interchangeable — think of it as **metadata for machines, custom fields 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 [Custom Fields](../core-concepts/metafields.md) instead.

## Store API

### Cart creation

Set metadata when creating a new cart:

```bash
POST /api/v3/store/carts
Content-Type: application/json

{
  "metadata": {
    "source": "mobile_app",
    "campaign": "summer_sale"
  }
}
```

```typescript
// @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:

```bash
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."
  }
}
```

```typescript
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.

```bash
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:

```typescript
// 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

```bash
PATCH /api/v3/store/carts/:id
Content-Type: application/json

{
  "metadata": {
    "utm_source": "google",
    "utm_campaign": "summer_sale"
  }
}
```

```typescript
await client.carts.update(cartId, {
  metadata: { utm_source: 'google' },
}, { spreeToken })
```

## Admin API

Metadata is **readable** in Admin API responses on orders and line items:

```json
{
  "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.)

```ruby
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

```ruby
# 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](https://docs.stripe.com/api/metadata).

```
# 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 Custom Fields

Spree has two permanent, complementary systems for custom data. They are not interchangeable and neither is going away.

| | Metadata | [Custom Fields](../core-concepts/metafields.md) |
|---|---|---|
| **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 CustomFieldDefinitions (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 |

### 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 custom fields

- 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

> **WARNING:** `public_metadata` is deprecated. Use `metadata` instead.

The `public_metadata` column was never exposed in Store API responses and in practice served the same purpose as `private_metadata`. Calling `public_metadata=` emits 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 [Custom Fields](../core-concepts/metafields.md) instead.

```ruby
# Deprecated — use `metadata` instead
order.public_metadata = { 'gift_message' => 'Happy Birthday!' }

# Use metadata for internal storage
order.metadata = { 'gift_message' => 'Happy Birthday!' }

# Use custom fields for customer-visible structured data
order.set_custom_field('custom.gift_message', 'Happy Birthday!')
```
