@spree/docs 0.1.229 → 0.1.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/api-reference/store.yaml +78 -78
  2. package/dist/developer/agentic/agent-skills.md +1 -1
  3. package/dist/developer/cli/quickstart.md +2 -2
  4. package/dist/developer/core-concepts/addresses.md +13 -0
  5. package/dist/developer/core-concepts/calculators.md +6 -1
  6. package/dist/developer/core-concepts/carts.md +19 -1
  7. package/dist/developer/core-concepts/catalogs.md +51 -2
  8. package/dist/developer/core-concepts/channels.md +20 -1
  9. package/dist/developer/core-concepts/commissions.md +70 -1
  10. package/dist/developer/core-concepts/companies.md +74 -0
  11. package/dist/developer/core-concepts/customers.md +17 -5
  12. package/dist/developer/core-concepts/data-privacy.md +20 -7
  13. package/dist/developer/core-concepts/delivery-setup.md +57 -0
  14. package/dist/developer/core-concepts/discounts.md +7 -0
  15. package/dist/developer/core-concepts/events.md +13 -7
  16. package/dist/developer/core-concepts/fees.md +20 -0
  17. package/dist/developer/core-concepts/fulfillments.md +3 -3
  18. package/dist/developer/core-concepts/imports-exports.md +21 -5
  19. package/dist/developer/core-concepts/inventory.md +73 -1
  20. package/dist/developer/core-concepts/markets.md +5 -6
  21. package/dist/developer/core-concepts/media.md +66 -1
  22. package/dist/developer/core-concepts/metafields.md +1 -1
  23. package/dist/developer/core-concepts/order-totals.md +18 -1
  24. package/dist/developer/core-concepts/orders.md +2 -1
  25. package/dist/developer/core-concepts/payments.md +100 -20
  26. package/dist/developer/core-concepts/pricing.md +13 -8
  27. package/dist/developer/core-concepts/products.md +52 -1
  28. package/dist/developer/core-concepts/reporting.md +5 -5
  29. package/dist/developer/core-concepts/returns-exchanges-claims.md +41 -2
  30. package/dist/developer/core-concepts/search-filtering.md +1 -2
  31. package/dist/developer/core-concepts/sellers.md +109 -2
  32. package/dist/developer/core-concepts/slugs.md +1 -1
  33. package/dist/developer/core-concepts/staff-roles.md +23 -3
  34. package/dist/developer/core-concepts/stores.md +21 -0
  35. package/dist/developer/core-concepts/taxes.md +43 -0
  36. package/dist/developer/core-concepts/translations.md +9 -0
  37. package/dist/developer/core-concepts/webhooks.md +45 -3
  38. package/dist/developer/create-spree-app/quickstart.md +10 -10
  39. package/dist/developer/dashboard/deployment.md +4 -4
  40. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +1 -1
  41. package/dist/developer/deployment/docker.md +2 -2
  42. package/dist/developer/how-to/custom-promotion.md +33 -13
  43. package/dist/developer/multi-tenant/quickstart.md +6 -6
  44. package/dist/developer/storefront/nextjs/testing.md +2 -2
  45. package/package.json +1 -1
@@ -62,6 +62,7 @@ Note the difference between `value` and `amount`. `value` is the rule — "10 pe
62
62
 
63
63
  Whatever produced them, the rows read the same way:
64
64
 
65
+
65
66
  ```typescript Store SDK
66
67
  const cart = await client.carts.get(cartId)
67
68
 
@@ -75,6 +76,12 @@ cart.discounts.forEach((discount) => {
75
76
  })
76
77
  ```
77
78
 
79
+ ```bash cURL
80
+ curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
81
+ -H 'X-Spree-API-Key: pk_xxx'
82
+ ```
83
+
84
+
78
85
  Applying a coupon code, and what decides whether it's accepted, belongs to [Promotions](promotions.md).
79
86
 
80
87
  ## Manual discounts
@@ -57,7 +57,7 @@ The distinction matters when choosing what to listen for. `order.updated` fires
57
57
 
58
58
  An event carries the record it's about, serialized:
59
59
 
60
- ```json
60
+ ```json Event payload
61
61
  {
62
62
  "id": "or_86Rf07xd4z",
63
63
  "number": "R123456789",
@@ -78,13 +78,19 @@ Point a webhook at your endpoint and subscribe it to the events you care about.
78
78
 
79
79
  A subscriber is a small class that names the events it wants and does something when one arrives.
80
80
 
81
- ```bash
81
+
82
+ ```bash Spree CLI (Docker)
83
+ spree generate subscriber OrderPlaced order.placed
84
+ ```
85
+
86
+ ```bash Without Spree CLI
82
87
  bin/rails g spree:subscriber OrderPlaced order.placed
83
88
  ```
84
89
 
90
+
85
91
  The generator writes the class, a test, and — the step that's easy to forget — registers it.
86
92
 
87
- ```ruby app/subscribers/order_placed_subscriber.rb
93
+ ```ruby server/app/subscribers/order_placed_subscriber.rb
88
94
  class OrderPlacedSubscriber < Spree::Subscriber
89
95
  subscribes_to 'order.placed'
90
96
 
@@ -99,14 +105,14 @@ end
99
105
 
100
106
  A subscriber can listen to several events, or to a whole family:
101
107
 
102
- ```ruby
108
+ ```ruby server/app/subscribers/order_placed_subscriber.rb
103
109
  subscribes_to 'order.placed', 'order.canceled'
104
110
  subscribes_to 'order.*'
105
111
  ```
106
112
 
107
113
  Subscribers must be registered — they aren't discovered automatically, because a subscriber that starts running because of where its file sits is hard to reason about:
108
114
 
109
- ```ruby config/initializers/spree.rb
115
+ ```ruby server/config/initializers/spree.rb
110
116
  Rails.application.config.after_initialize do
111
117
  Spree.subscribers << OrderPlacedSubscriber
112
118
  end
@@ -118,7 +124,7 @@ By default a subscriber runs as a background job, so a slow API call in your cod
118
124
 
119
125
  That's almost always what you want. If you genuinely need to run inside the same transaction, you can ask to:
120
126
 
121
- ```ruby
127
+ ```ruby server/app/subscribers/order_placed_subscriber.rb
122
128
  subscribes_to 'order.placed', async: false
123
129
  ```
124
130
 
@@ -128,7 +134,7 @@ subscribes_to 'order.placed', async: false
128
134
 
129
135
  Anything in your own code can announce something, and subscribers and webhooks treat it like any built-in event:
130
136
 
131
- ```ruby
137
+ ```ruby server/app/services/fraud_check.rb
132
138
  order.publish_event('order.flagged_for_review')
133
139
  ```
134
140
 
@@ -79,6 +79,7 @@ curl -X POST 'https://api.mystore.com/api/v3/admin/orders/or_xxx/fees' \
79
79
 
80
80
  Fees are visible to the storefront on the cart and the order, so a customer sees what they're being charged before they pay:
81
81
 
82
+
82
83
  ```typescript Store SDK
83
84
  const cart = await client.carts.get(cartId)
84
85
 
@@ -90,6 +91,12 @@ cart.fees.forEach((fee) => {
90
91
  cart.display_fee_total // "$7.50"
91
92
  ```
92
93
 
94
+ ```bash cURL
95
+ curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
96
+ -H 'X-Spree-API-Key: pk_xxx'
97
+ ```
98
+
99
+
93
100
  ## Fees and tax
94
101
 
95
102
  Fees are **taxable by default** — a handling charge in a VAT country is itself subject to VAT, and Spree writes tax lines against the fee accordingly.
@@ -122,6 +129,7 @@ For any of this to work, the goods have to be described in the language customs
122
129
  | `country_of_origin` | Where it was made, as an ISO country code — not where it ships from | `PT` |
123
130
  | `customs_description` | A plain description for the declaration, when the product name isn't clear enough | `Cotton t-shirt` |
124
131
 
132
+
125
133
  ```typescript Admin SDK
126
134
  await adminClient.products.variants.update('prod_xxx', 'var_xxx', {
127
135
  hs_code: '6109.10',
@@ -130,6 +138,18 @@ await adminClient.products.variants.update('prod_xxx', 'var_xxx', {
130
138
  })
131
139
  ```
132
140
 
141
+ ```bash cURL
142
+ curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx/variants/var_xxx' \
143
+ -H 'X-Spree-API-Key: sk_xxx' \
144
+ -H 'Content-Type: application/json' \
145
+ -d '{
146
+ "hs_code": "6109.10",
147
+ "country_of_origin": "PT",
148
+ "customs_description": "Cotton t-shirt"
149
+ }'
150
+ ```
151
+
152
+
133
153
  > **WARNING:** `country_of_origin` is where the item was **manufactured**, not the warehouse it leaves from. Duty rates and trade agreements turn on origin, so getting this wrong produces the wrong charge — and can hold a parcel at the border.
134
154
 
135
155
  These fields are editable per variant and in the bulk product editor. They're also what a carrier integration puts on the customs declaration for an international label — required for cross-border parcels, ignored for domestic ones.
@@ -322,7 +322,7 @@ integration's metadata, not in the parcel's lifecycle vocabulary. What core
322
322
  needs to know is only *whether the parcel may go out yet*, and that is exactly
323
323
  what the fulfill workflow's validate hook expresses:
324
324
 
325
- ```ruby
325
+ ```ruby server/config/initializers/spree.rb
326
326
  Spree.hooks.register('fulfillments.fulfill.validate') do |flow|
327
327
  work_order = MyApp::WorkOrder.find_by(fulfillment: flow.fulfillment)
328
328
  flow.reject!('still in production') unless work_order&.completed?
@@ -337,7 +337,7 @@ orders render `fulfilled` as "ready for pickup".
337
337
  by it, reports group by it, or webhooks fire on entering it, make it a
338
338
  first-class status:
339
339
 
340
- ```ruby config/initializers/spree.rb
340
+ ```ruby server/config/initializers/spree.rb
341
341
  Spree::Fulfillment.add_status('in_production', after: 'unfulfilled')
342
342
  ```
343
343
 
@@ -346,7 +346,7 @@ a valid value. Moving into it is deliberately not declarative — write a small
346
346
  workflow, which is also where the printer submission or the workshop handoff
347
347
  belongs:
348
348
 
349
- ```ruby
349
+ ```ruby server/app/workflows/my_app/fulfillments/start_production.rb
350
350
  class MyApp::Fulfillments::StartProduction < Spree::Workflow
351
351
  def perform(fulfillment:)
352
352
  super
@@ -40,7 +40,7 @@ An export is created, runs in the background, and produces a file to download.
40
40
 
41
41
  ```typescript Admin SDK
42
42
  const exportJob = await adminClient.exports.create({
43
- type: 'Spree::Exports::Products',
43
+ type: 'products',
44
44
  })
45
45
 
46
46
  // Poll until it's done
@@ -50,7 +50,7 @@ status.download_url // available once completed
50
50
  ```
51
51
 
52
52
  ```bash CLI
53
- spree api post /exports -d '{"type": "Spree::Exports::Products"}'
53
+ spree api post /exports -d '{"type": "products"}'
54
54
  spree api get /exports
55
55
  ```
56
56
 
@@ -59,13 +59,22 @@ spree api get /exports
59
59
 
60
60
  An export can carry the same filters as the listing it came from, so "export what I'm looking at" does what a merchant expects — this quarter's orders, this brand's products, not the entire table.
61
61
 
62
+
62
63
  ```typescript Admin SDK
63
64
  await adminClient.exports.create({
64
- type: 'Spree::Exports::Orders',
65
+ type: 'orders',
65
66
  filters: { completed_at_gteq: '2026-01-01' },
66
67
  })
67
68
  ```
68
69
 
70
+ ```bash cURL
71
+ curl -X POST 'https://api.mystore.com/api/v3/admin/exports' \
72
+ -H 'X-Spree-API-Key: sk_xxx' \
73
+ -H 'Content-Type: application/json' \
74
+ -d '{ "type": "orders", "filters": { "completed_at_gteq": "2026-01-01" } }'
75
+ ```
76
+
77
+
69
78
  In the dashboard this is the Export button on any list — whatever filters are applied come along.
70
79
 
71
80
  ## Importing
@@ -76,7 +85,7 @@ Importing has an extra step, because a file from somewhere else won't have Spree
76
85
 
77
86
  ```typescript Admin SDK
78
87
  const importJob = await adminClient.imports.create({
79
- type: 'Spree::Imports::Products',
88
+ type: 'products',
80
89
  file: uploadedFileId,
81
90
  })
82
91
  ```
@@ -117,9 +126,10 @@ Some rows will fail. A price with a currency symbol in it, a required field left
117
126
 
118
127
  **A failed row doesn't stop the import.** The good rows are imported; the bad ones are set aside with the reason.
119
128
 
129
+
120
130
  ```typescript Admin SDK
121
131
  const { data: failures } = await adminClient.imports.rows.list(importJob.id, {
122
- filter: { status_eq: 'failed' },
132
+ status_eq: 'failed',
123
133
  })
124
134
 
125
135
  failures.forEach((row) => {
@@ -131,6 +141,12 @@ failures.forEach((row) => {
131
141
  await adminClient.imports.retryFailedRows(importJob.id)
132
142
  ```
133
143
 
144
+ ```bash cURL
145
+ curl 'https://api.mystore.com/api/v3/admin/imports/imp_xxx/rows?q[status_eq]=failed' \
146
+ -H 'X-Spree-API-Key: sk_xxx'
147
+ ```
148
+
149
+
134
150
  Retrying re-runs only the failures, so a file where three rows out of ten thousand were wrong doesn't need re-importing whole.
135
151
 
136
152
  > **WARNING:** A value that isn't a number is rejected, never guessed at. A price written as `"12,50"` fails rather than being read as `1250` — which would be a hundredfold overcharge. Fix the file rather than hoping the import is lenient.
@@ -51,7 +51,17 @@ A stock level carries four counts, and a fifth is derived from them. Keeping the
51
51
  | `incoming_count` | How much is on its way here — on a purchase order placed with a supplier, or a transfer already in transit |
52
52
  | **`available_count`** | `count_on_hand − allocated_count` — what's actually sellable, before checkout holds |
53
53
 
54
- Reserved and incoming are kept as counters rather than worked out on every read, because the Inventory page in the dashboard reads them for every row, many times a day, and only a handful of things change them: entering or leaving checkout moves reserved; marking a purchase order ordered or a transfer in transit adds to incoming, and receiving, closing, cancelling or reopening the document takes the remainder back off. Nothing else writes them. If a figure ever looks wrong, `rake spree:stock:recount_levels` recomputes both from their sources and reports every row it corrected — the same task the 6.0 upgrade runs to fill them in for existing stock.
54
+ Reserved and incoming are kept as counters rather than worked out on every read, because the Inventory page in the dashboard reads them for every row, many times a day, and only a handful of things change them: entering or leaving checkout moves reserved; marking a purchase order ordered or a transfer in transit adds to incoming, and receiving, closing, cancelling or reopening the document takes the remainder back off. Nothing else writes them. If a figure ever looks wrong, one task recomputes both from their sources and reports every row it corrected — the same task the 6.0 upgrade runs to fill them in for existing stock.
55
+
56
+
57
+ ```bash Spree CLI (Docker)
58
+ spree rake spree:stock:recount_levels
59
+ ```
60
+
61
+ ```bash Without Spree CLI
62
+ bundle exec rake spree:stock:recount_levels
63
+ ```
64
+
55
65
 
56
66
  A transfer counts as incoming from the moment it is marked in transit, not when it is drafted: until the van leaves, nothing is moving. Units a delivery rejects at the dock leave incoming too — they are recorded on the receipt, but they are not coming.
57
67
 
@@ -74,6 +84,7 @@ A stock location is somewhere stock physically sits: a warehouse, a shop, a thir
74
84
  | `pickup_enabled` | Whether customers can collect here |
75
85
  | `returns_enabled` | Whether customers can bring returns here |
76
86
 
87
+
77
88
  ```typescript Admin SDK
78
89
  const { data: locations } = await adminClient.stockLocations.list()
79
90
 
@@ -85,6 +96,12 @@ await adminClient.stockLocations.create({
85
96
  })
86
97
  ```
87
98
 
99
+ ```bash cURL
100
+ curl 'https://api.mystore.com/api/v3/admin/stock_locations' \
101
+ -H 'X-Spree-API-Key: sk_xxx'
102
+ ```
103
+
104
+
88
105
  ## Reading and adjusting stock
89
106
 
90
107
  A stock level is the intersection of a variant and a location. They're created for you — for every variant when a location propagates all variants, or as variants are added.
@@ -131,6 +148,7 @@ Every change to stock is recorded as a movement, so "why is this number what it
131
148
  | `released` | An allocation is given back, because an order was cancelled |
132
149
  | `adjusted` | A manual correction, such as after a stock count |
133
150
 
151
+
134
152
  ```typescript Admin SDK
135
153
  // Everything that happened to one SKU, across every location
136
154
  const { data: movements } = await adminClient.stockMovements.list({
@@ -138,6 +156,12 @@ const { data: movements } = await adminClient.stockMovements.list({
138
156
  })
139
157
  ```
140
158
 
159
+ ```bash cURL
160
+ curl 'https://api.mystore.com/api/v3/admin/stock_movements?q[stock_level_variant_id_eq]=variant_xxx' \
161
+ -H 'X-Spree-API-Key: sk_xxx'
162
+ ```
163
+
164
+
141
165
  Each movement points at what caused it — the order, the fulfillment, the transfer, the purchase order, and for stock that arrived, the delivery it came in on — so an audit trail reads as a sequence of business events rather than a list of numbers that changed.
142
166
 
143
167
  A movement that arrived through a purchase order also carries what the units cost, so "what did this stock cost us" is answerable from the ledger rather than reconstructed from the orders that bought it.
@@ -146,6 +170,7 @@ A movement that arrived through a purchase order also carries what the units cos
146
170
 
147
171
  For a storefront, all of the above collapses into one question, and the answer is already computed:
148
172
 
173
+
149
174
  ```typescript Store SDK
150
175
  const product = await client.products.get('spree-tote')
151
176
 
@@ -155,6 +180,12 @@ product.variants.forEach((variant) => {
155
180
  })
156
181
  ```
157
182
 
183
+ ```bash cURL
184
+ curl 'https://api.mystore.com/api/v3/store/products/spree-tote' \
185
+ -H 'X-Spree-API-Key: pk_xxx'
186
+ ```
187
+
188
+
158
189
  `in_stock` already accounts for what's allocated and what other shoppers are holding in checkout. There is nothing to compute client-side, and no separate inventory request to make.
159
190
 
160
191
  ## Reservations during checkout
@@ -188,6 +219,7 @@ A stock transfer moves inventory from one location to another, and reflects that
188
219
 
189
220
  That is four steps, not one. A transfer is planned as a draft, packed, sent, and finally counted in:
190
221
 
222
+
191
223
  ```typescript Admin SDK
192
224
  const transfer = await adminClient.stockTransfers.create({
193
225
  source_location_id: 'sloc_xxx',
@@ -204,6 +236,14 @@ await adminClient.stockTransfers.stockReceipts.create(transfer.id, {
204
236
  })
205
237
  ```
206
238
 
239
+ ```bash cURL
240
+ curl -X POST 'https://api.mystore.com/api/v3/admin/stock_transfers' \
241
+ -H 'X-Spree-API-Key: sk_xxx' \
242
+ -H 'Content-Type: application/json' \
243
+ -d '{ "source_location_id": "sl_a", "destination_location_id": "sl_b" }'
244
+ ```
245
+
246
+
207
247
  | Status | What it means |
208
248
  |---|---|
209
249
  | `draft` | A plan. Lines can still change; nothing has moved |
@@ -220,6 +260,7 @@ Two things follow from the middle step. Availability at the destination does not
220
260
 
221
261
  Stock that comes from a supplier is a purchase order, not a transfer — it has a supplier, a cost per unit and a date it was promised for, none of which apply to moving goods you already own.
222
262
 
263
+
223
264
  ```typescript Admin SDK
224
265
  const order = await adminClient.purchaseOrders.create({
225
266
  supplier_id: 'sup_xxx',
@@ -238,6 +279,14 @@ await adminClient.purchaseOrders.stockReceipts.create(order.id, {
238
279
  })
239
280
  ```
240
281
 
282
+ ```bash cURL
283
+ curl -X POST 'https://api.mystore.com/api/v3/admin/purchase_orders' \
284
+ -H 'X-Spree-API-Key: sk_xxx' \
285
+ -H 'Content-Type: application/json' \
286
+ -d '{ "supplier_id": "sup_xxx", "stock_location_id": "sl_xxx" }'
287
+ ```
288
+
289
+
241
290
  | Status | What it means |
242
291
  |---|---|
243
292
  | `draft` | Being put together. Lines and costs can still change |
@@ -255,31 +304,54 @@ Purchase orders export to CSV, one row per line, which is the file a merchant se
255
304
 
256
305
  Suppliers themselves are an address book, kept per store:
257
306
 
307
+
258
308
  ```typescript Admin SDK
259
309
  const { data: suppliers } = await adminClient.suppliers.list()
260
310
  ```
261
311
 
312
+ ```bash cURL
313
+ curl 'https://api.mystore.com/api/v3/admin/suppliers' \
314
+ -H 'X-Spree-API-Key: sk_xxx'
315
+ ```
316
+
317
+
262
318
  ## Counting a delivery in
263
319
 
264
320
  Transfers and purchase orders are received the same way: each delivery is a **stock receipt**, numbered and dated, carrying the packing-slip reference and — per line — what was accepted and what was refused, with a reason.
265
321
 
322
+
266
323
  ```typescript Admin SDK
267
324
  const { data: deliveries } = await adminClient.purchaseOrders.stockReceipts.list(order.id, {
268
325
  expand: ['items'],
269
326
  })
270
327
  ```
271
328
 
329
+ ```bash cURL
330
+ curl 'https://api.mystore.com/api/v3/admin/purchase_orders/po_xxx/stock_receipts' \
331
+ -H 'X-Spree-API-Key: sk_xxx'
332
+ ```
333
+
334
+
272
335
  A receipt records *this delivery's* counts, not running totals: a second box adds to the first rather than restating it. Accepted units land on the destination's shelf, each as a `received` movement that names the receipt. Refused units are recorded and never stocked; the reason is one of `damaged`, `wrong_item`, `expired` or `other`. Omit `items` to count in everything still outstanding, intact.
273
336
 
274
337
  > **NOTE:** **Refused units count differently on the two documents.** A supplier's refused units are still owed — the goods go back, and the order stays `partially_received` until they are replaced. A transfer's refused units have *arrived*, in whatever state: a crushed unit is not still in the van, so it counts toward the trip being over.
275
338
 
276
339
  When the balance is not coming — the supplier is out of stock, the missing units fell off the van — the document is **closed short**: it settles in `received` with `closed_short_at` and the reason recorded, and the outstanding count stays on each line as the record of the gap. Nothing moves.
277
340
 
341
+
278
342
  ```typescript Admin SDK
279
343
  await adminClient.purchaseOrders.close(order.id, { reason: 'Supplier out of stock' })
280
344
  await adminClient.stockTransfers.close(transfer.id, { reason: 'Two fell off the van' })
281
345
  ```
282
346
 
347
+ ```bash cURL
348
+ curl -X PATCH 'https://api.mystore.com/api/v3/admin/purchase_orders/po_xxx/close' \
349
+ -H 'X-Spree-API-Key: sk_xxx' \
350
+ -H 'Content-Type: application/json' \
351
+ -d '{ "reason": "Supplier out of stock" }'
352
+ ```
353
+
354
+
283
355
  ## The Inventory page
284
356
 
285
357
  The dashboard's Inventory section opens on a list with one row per variant per location: on hand, allocated, reserved, available and incoming — the page uses the column names, with the location as a filter and SKU or product name as the search. Every figure on it is a column read off the stock level.
@@ -14,8 +14,8 @@ Markets let you segment a single [Store](stores.md) into distinct geographic reg
14
14
  ```mermaid
15
15
  erDiagram
16
16
  Store ||--o{ Market : "has many"
17
- Market ||--o{ MarketCountry : "has many"
18
- MarketCountry }o--|| Country : "belongs to"
17
+ Market ||--o{ MarketCountry : "covers"
18
+ Market ||--o{ Order : "records"
19
19
 
20
20
  Store {
21
21
  string name
@@ -31,9 +31,8 @@ erDiagram
31
31
  boolean default
32
32
  }
33
33
 
34
- Country {
35
- string iso
36
- string name
34
+ MarketCountry {
35
+ string country_code
37
36
  }
38
37
  ```
39
38
 
@@ -53,7 +52,7 @@ erDiagram
53
52
 
54
53
  When a customer visits your store, their country determines which market applies. The market then sets the currency, locale, and tax behavior for that session.
55
54
 
56
- ```
55
+ ```text
57
56
  Customer's Country → Market → Currency + Locale + Tax treatment
58
57
  ```
59
58
 
@@ -22,6 +22,7 @@ flowchart LR
22
22
 
23
23
  Every file in a store is in the library, whether or not it's currently placed on anything. That means a file can be uploaded ahead of time, browsed, searched, and reused.
24
24
 
25
+
25
26
  ```typescript Admin SDK
26
27
  // Browse everything in the store
27
28
  const { data: media } = await adminClient.media.list()
@@ -30,6 +31,17 @@ const { data: media } = await adminClient.media.list()
30
31
  const { data: usage } = await adminClient.media.usage('med_xxx')
31
32
  ```
32
33
 
34
+ ```bash cURL
35
+ # Browse everything in the store
36
+ curl 'https://api.mystore.com/api/v3/admin/media' \
37
+ -H 'X-Spree-API-Key: sk_xxx'
38
+
39
+ # Where is this file being used?
40
+ curl 'https://api.mystore.com/api/v3/admin/media/med_xxx/usage' \
41
+ -H 'X-Spree-API-Key: sk_xxx'
42
+ ```
43
+
44
+
33
45
  | Field | What it tells you |
34
46
  |---|---|
35
47
  | `attached` | Whether the file is placed on anything yet |
@@ -43,6 +55,7 @@ const { data: usage } = await adminClient.media.usage('med_xxx')
43
55
 
44
56
  Placing a library file on a product creates a **new media row that shares the same underlying file**:
45
57
 
58
+
46
59
  ```typescript Admin SDK
47
60
  await adminClient.products.media.create('prod_xxx', {
48
61
  source_media_id: 'med_xxx',
@@ -51,6 +64,14 @@ await adminClient.products.media.create('prod_xxx', {
51
64
  })
52
65
  ```
53
66
 
67
+ ```bash cURL
68
+ curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_xxx/media' \
69
+ -H 'X-Spree-API-Key: sk_xxx' \
70
+ -H 'Content-Type: application/json' \
71
+ -d '{ "source_media_id": "med_xxx", "alt": "Worn over a navy jumper", "position": 2 }'
72
+ ```
73
+
74
+
54
75
  The copy gets its own alt text, its own position, and its own variant links — because the same photograph might be the third image on one product and the hero on another, with different alt text describing what matters in each context. What it doesn't get is a second copy of the bytes.
55
76
 
56
77
  > **NOTE:** This is why there's no single "shared asset" record to manage. Each placement is independently editable, while storage is shared. Detaching a photo from one product has no effect on any other.
@@ -59,6 +80,7 @@ The copy gets its own alt text, its own position, and its own variant links —
59
80
 
60
81
  A file in use can't be deleted by accident:
61
82
 
83
+
62
84
  ```typescript Admin SDK
63
85
  // Returns 422 with the list of places it's used
64
86
  await adminClient.media.delete('med_xxx')
@@ -67,6 +89,17 @@ await adminClient.media.delete('med_xxx')
67
89
  await adminClient.media.delete('med_xxx', { detach: true })
68
90
  ```
69
91
 
92
+ ```bash cURL
93
+ # Returns 422 with the list of places it is used
94
+ curl -X DELETE 'https://api.mystore.com/api/v3/admin/media/med_xxx' \
95
+ -H 'X-Spree-API-Key: sk_xxx'
96
+
97
+ # Remove it from everywhere, then delete
98
+ curl -X DELETE 'https://api.mystore.com/api/v3/admin/media/med_xxx?detach=true' \
99
+ -H 'X-Spree-API-Key: sk_xxx'
100
+ ```
101
+
102
+
70
103
  The dashboard shows you the usage list and asks before doing the second one. Check `usage` before offering a delete in your own tooling.
71
104
 
72
105
  ## What can carry media
@@ -227,6 +260,7 @@ curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/medi
227
260
 
228
261
  A hosted video is uploaded the same way an image is, with `media_type` telling Spree what it is. Add `poster_signed_id` to give it a still frame:
229
262
 
263
+
230
264
  ```typescript Admin SDK
231
265
  const video = await client.products.media.create('prod_86Rf07xd4z', {
232
266
  media_type: 'video',
@@ -236,14 +270,36 @@ const video = await client.products.media.create('prod_86Rf07xd4z', {
236
270
  })
237
271
  ```
238
272
 
273
+ ```bash cURL
274
+ curl -X POST 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media' \
275
+ -H 'X-Spree-API-Key: pk_xxx' \
276
+ -H 'Content-Type: application/json' \
277
+ -d '{
278
+ "media_type": "video",
279
+ "signed_id": "SIGNED_VIDEO_BLOB_ID",
280
+ "poster_signed_id": "SIGNED_POSTER_BLOB_ID",
281
+ "alt": "Product in use"
282
+ }'
283
+ ```
284
+
285
+
239
286
  A poster can also be added or replaced later, on its own:
240
287
 
288
+
241
289
  ```typescript Admin SDK
242
290
  await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
243
291
  poster_signed_id: signedPosterBlobId,
244
292
  })
245
293
  ```
246
294
 
295
+ ```bash cURL
296
+ curl -X PATCH 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
297
+ -H 'X-Spree-API-Key: pk_xxx' \
298
+ -H 'Content-Type: application/json' \
299
+ -d '{ "poster_signed_id": "SIGNED_POSTER_BLOB_ID" }'
300
+ ```
301
+
302
+
247
303
  > **INFO:** Spree serves an uploaded video as you uploaded it — it does not transcode. Keep files web-friendly (H.264 MP4 or WebM) so they play everywhere, and prefer an external video for long footage so the provider handles streaming.
248
304
 
249
305
  #### Posters
@@ -262,6 +318,7 @@ Spree does not extract a frame from an uploaded video, so give hosted video and
262
318
 
263
319
  `focal_point_x` and `focal_point_y` mark the part of an image that must stay in frame when a storefront crops it to a different shape. Both are fractions between 0 and 1, measured from the top left, so `{ x: 0.5, y: 0.5 }` is dead centre — which is also what a storefront should assume when they are null.
264
320
 
321
+
265
322
  ```typescript Admin SDK
266
323
  await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
267
324
  focal_point_x: 0.25,
@@ -269,6 +326,14 @@ await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
269
326
  })
270
327
  ```
271
328
 
329
+ ```bash cURL
330
+ curl -X PATCH 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
331
+ -H 'X-Spree-API-Key: pk_xxx' \
332
+ -H 'Content-Type: application/json' \
333
+ -d '{ "focal_point_x": 0.25, "focal_point_y": 0.4 }'
334
+ ```
335
+
336
+
272
337
  Spree stores the focal point and serves it; the cropping itself is the storefront's decision, since only it knows the shape it needs.
273
338
 
274
339
  ### Named Variant Sizes
@@ -349,7 +414,7 @@ curl 'https://api.mystore.com/api/v3/store/products/spree-tote?expand=media,vari
349
414
 
350
415
  **Response (media object):**
351
416
 
352
- ```json
417
+ ```json Response
353
418
  {
354
419
  "id": "media_k5nR8xLq",
355
420
  "media_type": "image",
@@ -170,7 +170,7 @@ A custom field can take part in product listings — but only if you ask, becaus
170
170
 
171
171
  Setting either flag also makes the field **filterable**. Filter and sort with the field's `cf_` key, on both APIs:
172
172
 
173
- ```
173
+ ```http
174
174
  GET /api/v3/store/products?q[cf_properties_material_i_cont]=wool
175
175
  GET /api/v3/store/products?sort=-cf_properties_weight
176
176
  GET /api/v3/admin/products?q[cf_properties_weight_gteq]=3.5
@@ -22,6 +22,7 @@ flowchart TB
22
22
 
23
23
  ## The totals
24
24
 
25
+
25
26
  ```typescript Store SDK
26
27
  const order = await client.orders.get('or_xxx')
27
28
 
@@ -33,6 +34,13 @@ order.display_tax_total // "$22.60" all tax
33
34
  order.display_total // "$138.10" what the customer pays
34
35
  ```
35
36
 
37
+ ```bash cURL
38
+ curl 'https://api.mystore.com/api/v3/store/orders/or_xxx' \
39
+ -H 'X-Spree-API-Key: pk_xxx' \
40
+ -H 'Authorization: Bearer $CUSTOMER_JWT'
41
+ ```
42
+
43
+
36
44
  | Attribute | What it sums |
37
45
  |---|---|
38
46
  | `item_total` | Line item prices, before tax and discounts |
@@ -49,7 +57,7 @@ order.display_total // "$138.10" what the customer pays
49
57
 
50
58
  Every total comes twice: `total` is the raw value, `display_total` is formatted for the order's currency.
51
59
 
52
- ```json
60
+ ```json Response
53
61
  {
54
62
  "total": "138.10",
55
63
  "display_total": "$138.10"
@@ -74,6 +82,7 @@ The two exist because the same order can carry both: VAT already inside the good
74
82
 
75
83
  Every change to a cart returns the whole cart, totals included:
76
84
 
85
+
77
86
  ```typescript Store SDK
78
87
  const cart = await client.carts.items.create(cartId, {
79
88
  variant_id: 'var_xxx',
@@ -83,6 +92,14 @@ const cart = await client.carts.items.create(cartId, {
83
92
  cart.display_total // already correct
84
93
  ```
85
94
 
95
+ ```bash cURL
96
+ curl -X POST 'https://api.mystore.com/api/v3/store/carts/cart_xxx/items' \
97
+ -H 'X-Spree-API-Key: pk_xxx' \
98
+ -H 'Content-Type: application/json' \
99
+ -d '{ "variant_id": "variant_xxx", "quantity": 2 }'
100
+ ```
101
+
102
+
86
103
  There is never a second "recalculate" request to make, and never a moment where the summary on screen disagrees with what the server thinks. Adding an item, entering an address, applying a code — each response carries the new numbers.
87
104
 
88
105
  The totals are worked out again at the moment the cart is completed, so a price that changed while the customer sat on the review page can't lead to the wrong charge.
@@ -114,7 +114,8 @@ Back-office work happens through the Admin API.
114
114
  ```typescript Admin SDK
115
115
  // Find orders needing attention
116
116
  const orders = await adminClient.orders.list({
117
- filter: { payment_status_eq: 'paid', fulfillment_status_eq: 'unfulfilled' },
117
+ payment_status_eq: 'paid',
118
+ fulfillment_status_eq: 'unfulfilled',
118
119
  })
119
120
 
120
121
  // Cancel an order, recording why from your own list of reasons