Entity & Context Reference
This is the field-level companion to Plugins.md. Plugins.md documents
what the bridges and hooks do; this document documents the exact shape of the
data they hand you — the entity records returned by sw.products / sw.orders /
sw.customers / sw.coupons / sw.records, and the ctx object your hooks,
widgets, routes, and tasks receive.
Conventions used throughout:
- Empty fields are omitted. When a field is empty it is absent from the record
entirely — don't assume a key exists. Read defensively with optional chaining or
defaults:
const tags = product.tags || []. - Some fields are never exposed to plugins — a customer's password and the
internal shop-scoping id are invisible. Scoping is implicit (every
sw.*op runs within the current shop), so you never set or read it. - Money is integer cents (
price: 2499= $24.99). Never floats. - Ids are integers and auto-allocated on first save (pass no
idto create). - Timestamps are RFC3339 strings (
created,updated) set by the platform; they're read-only — writing them back onsave()has no effect. - A ✎ marks a field you can set via
save(). Unmarked fields are platform-managed (read-only, or computed in a before-save hook). save()patches, it doesn't replace — see Saving withsave().
Saving with save()
sw.products.save / sw.orders.save / sw.customers.save / sw.coupons.save are
patches, not replacements. Pass an id and the stored record is loaded first,
then only the fields you sent are overlaid on it — so a two-field update never
clobbers the rest of the record:
sw.products.save({ id, stock: 4 }); // name, price, images, variants … all untouched
Omit id and you get a create instead: with nothing to overlay, unsent fields take
their defaults.
meta merges by key
meta follows the same rule one level deeper — keys you don't send survive, keys you
do send are replaced whole. There is no deep merge inside a key's value.
Starting from meta = { a: 1, b: "keep", nested: { x: 1, y: 2 } }:
| You send | Resulting meta |
|---|---|
meta: { a: 123 } | { a: 123, b: "keep", nested: { x: 1, y: 2 } } |
meta: { nested: { x: 9 } } | { a: 1, b: "keep", nested: { x: 9 } } — y is gone |
meta: {} | unchanged |
meta: null | cleared |
no meta key at all | unchanged |
To patch one field of a nested object, spread the stored one back in:
const p = sw.products.get(id);
sw.products.save({ id, meta: { nested: { ...(p.meta?.nested || {}), x: 9 } } });
Removing a key. Because unsent keys survive, you cannot delete a meta key by
dropping it from the object and saving the record back — the stored key is still
there afterwards:
const p = sw.products.get(id);
delete p.meta.stale;
sw.products.save(p); // ✗ no-op: `stale` survives the merge
Set the key to null instead. It stays present with a null value — which reads as
absent to any truthiness check, and, for a _-prefixed key, drops its entry from the
queryable index:
sw.products.save({ id, meta: { stale: null, _rinven_id: null } }); // ✓
Send meta: null to clear the whole map at once.
Product
Returned by sw.products.get/list/search; accepted by sw.products.save (which
patches the stored product). Also the
shape of ctx.data in product.before_save / product.after_save / product.*_delete.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create; set to update. |
shop_id | integer | The owning shop. Read-only. |
created / updated | string | RFC3339, read-only. |
sku ✎ | string | |
skus | []string | Read-only, derived: the product SKU plus every variant SKU (deduped). Rebuilt on each save. Filter by it to resolve a product from any of its SKUs — including a variant SKU: sw.products.list({ filters: { skus: "ABC-S" } }). |
index | []string | Read-only, derived on each save from two sources. meta keys that start with _: meta._foo = "bar" → entry "meta#foo#bar" (the meta# prefix namespaces meta-derived entries; numbers/booleans stringified; scalar arrays expand to one entry each). This is the queryable counterpart to the opaque meta blob — filter it to enumerate or resolve products by a hidden marker: sw.products.list({ filters: { index: "meta#rinven_id#123" } }). And one entry per tag, "tag#" + the tag's canonical form: lower-cased, with spaces, hyphens and underscores all reduced to a single hyphen and other punctuation dropped — so Men's Shoes, mens shoes and Mens-Shoes all yield "tag#mens-shoes". Use it to list products by tag without knowing how the tag was capitalized or punctuated: sw.products.list({ filters: { index: "tag#mens-shoes" } }). Equality only (it's multi-valued, so a range/* prefix over-matches) and standalone (no order), so it needs no composite index. |
name ✎ | string | Required. |
slug ✎ | string | Custom storefront slug; "" = derive URL from name+id. Handleized + unique per shop on save; setting it 301-redirects the old slug. Absent when empty. |
desc ✎ | string | |
price ✎ | integer | Selling price in cents. |
compare_price ✎ | integer | MSRP / strike-through price in cents. |
prices ✎ | object<string,integer> | Named price tiers, e.g. { "wholesale": 1999 }. Keyed by a price-level / customer price_level name. |
stock ✎ | integer | |
oversell ✎ | boolean | Allow back-orders past stock. |
weight ✎ | number | In lbs. |
images ✎ | []string | URLs. |
tags ✎ | []string | |
active ✎ | boolean | New products default to active: true. |
digital ✎ | boolean | Digital good (no shipping). |
files ✎ | []string | Digital-download file paths. |
options ✎ | []Option | Option dimensions (Size, Color). |
variants ✎ | []Variant | Per-combination SKU/price/stock. |
attrs ✎ | []Attribute | Custom attributes / facets. |
price_tiers ✎ | []PriceTier | Quantity price breaks. |
subscription ✎ | ProductSubscription | Recurring-purchase config; absent = one-time only. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable) — except keys beginning with _, which derive the queryable index field above. Merged by key on save, never replaced wholesale. See Plugins.md → Attaching plugin data (.meta). |
selected_set | object<string,string> | Only present on a storefront-priced product (the chosen variant); not persisted. |
Option
{ "name": "Size", "values": ["S", "M", "L"] }
Variant
| Field | Type | Notes |
|---|---|---|
set | object<string,string> | The option combination, e.g. { "Size": "S", "Color": "Red" }. |
sku | string | |
price | integer|null | null = use base price. |
compare_price | integer|null | |
prices | object<string,integer|null> | Per-tier overrides. |
stock | integer|null | null = use base stock. |
oversell | boolean|null | null = inherit product policy. |
images | []string | |
attrs | []Attribute | |
price_tiers | []PriceTier | Empty = use product-level tiers. |
PriceTier
{ "min_qty": 10, "price": 1999 } — applies when ordered qty ≥ min_qty; the highest matching min_qty wins. price in cents.
Attribute
{ "name": "Material", "value": "Cotton", "extra": { "facet": true } } — extra is optional (facet, hidden, …).
ProductSubscription
| Field | Type | Notes |
|---|---|---|
enabled | boolean | |
required | boolean | true = subscription-only (no one-time buy). |
trial_days | integer | |
max_cycles | integer | 0 = unlimited. |
plans | []SubscriptionPlan |
SubscriptionPlan
| Field | Type | Notes |
|---|---|---|
key | string | Stable id stored on the order line (e.g. "monthly"). |
label | string | Shown on the product page. |
interval | string | weekly | monthly | quarterly | yearly. |
price | integer|null | Fixed per-cycle cents; null = derive from product/variant price. |
discount | integer | % off base price when price is null. |
first_cycle_discount | integer | % off the first charge only. |
variant | object<string,string> | Option set this plan is scoped to; empty = all variants. |
anchor | string | Pins renewals to a calendar position instead of each customer's signup date. "" (default) = signup anniversary, day_of_month, day_of_week. |
anchor_value | string | The position anchor refers to: "1"–"31" or "last" for day_of_month; "mon"–"sun" for day_of_week. A day later than a given month has (the 31st in February) bills on that month's last day, then returns to the 31st in longer months. |
first_cycle | string | What happens between signup and the first anchored billing date: full (default) charges a whole cycle at signup, wait charges nothing until that date. Requires an anchor. |
bounds | SubscriptionBounds | Present when the plan's amount is decided per cycle rather than fixed. Absent = a plain fixed-price plan. |
anchor must match the cadence: only a weekly plan can use day_of_week, and only a
monthly/quarterly/yearly plan can use day_of_month. Saving a mismatched pair is
rejected.
SubscriptionBounds
The range a single billing cycle may move within, for a plan whose amount isn't
the same every time — weekly music lessons, where some months have four and some
have five. Your subscription.before_renew handler decides each cycle's actual
numbers; these bounds are what the store owner agreed those numbers may be.
| Field | Type | Notes |
|---|---|---|
variable | boolean | true = never bill this without an amount from a handler. A renewal nothing answers for is held for the store owner to review instead of charged at the plan price. Leave it off when the plan price is a sensible default. |
min_qty | integer | Lowest quantity a cycle may bill. 0 means a cycle may bill nothing at all ("no lessons in August"). |
max_qty | integer | Highest quantity a cycle may bill. 0 = quantity is locked to what the contract already says. |
min_price | integer|null | Lowest per-unit price in cents. null = a floor of 0. |
max_price | integer|null | Highest per-unit price in cents. null = the unit price is locked. |
An axis moves only if it has a maximum. A floor on its own doesn't authorize
anything, so min_qty without max_qty (or min_price without max_price) is
rejected rather than quietly ignored. Setting only quantity bounds leaves the
price locked, and vice versa — nothing becomes writable by accident.
These are snapshotted onto each contract when a customer subscribes, so editing a plan's bounds later changes what new subscribers agree to, never what existing ones already agreed to.
A store owner can adjust a live subscription's bounds from the admin; a plugin
cannot. sw.subscriptions.update has no bounds key and never will — these
are the limits your own per-cycle amounts are checked against, so a plugin that
could widen them would be marking its own homework. The asymmetry is deliberate
rather than an oversight: a store owner can already set any quantity or price
directly, so editing the limits grants them nothing new, and it saves cancelling
and re-subscribing a customer whose arrangement changed.
Order
Returned by sw.orders.get/list; accepted by sw.orders.save (which
patches the stored order — see Plugins.md → Orders). Also the shape of ctx.data in
order.before_save / order.after_save / order.*_delete.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
shop_id | integer | Owning shop. Read-only. |
created / updated | string | RFC3339, read-only. |
number ✎ | string | Human order number. Auto-generated (unique per shop) if you don't set it; setting a duplicate fails the save. |
status ✎ | string | One of created, processing, shipped, cancelled, refunded, partially_refunded, payment_failed. Any other value fails the save. Derived from the order's facts (payment.status, shipped_at, cancelled_at); writing a status performs the matching action — shipped records the shipment (stamps shipped_at), cancelled records the cancellation, processing captures an offline payment, refunded/partially_refunded/payment_failed update payment.status, and created reopens/resets. A write the facts contradict (e.g. payment_failed on a captured payment) is a no-op and the save returns the truthful status. |
currency ✎ | string | Absent when empty. |
customer ✎ | OrderCustomer | Snapshot at order time. |
shipping ✎ | Address | Ship-to address. |
payment ✎ | OrderPayment | |
totals ✎ | OrderTotals | |
items ✎ | []OrderItem | Line items. |
shipping_method ✎ | OrderShippingMethod | Absent when unset. |
trackings ✎ | []Tracking | Absent when empty. |
shipped_at ✎ | string | RFC3339; when the order was shipped. Absent until shipped. Setting status to shipped stamps it; useful as the anchor for time-window logic (e.g. returns). |
cancelled_at ✎ | string | RFC3339; when the order was cancelled. Absent unless cancelled. Setting status to cancelled stamps it. |
coupon_codes ✎ | []string | Absent when empty. |
tax_name ✎ | string | Label for the tax line. Absent when empty. |
digital_only | boolean | Computed from items in before-save. Read-only. |
reseller_shop_id | integer | Absent when 0. |
subscription_id | integer | Links a renewal invoice to its contract; absent for one-time orders. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable) — except keys beginning with _, which derive the queryable index field below. Merged by key on save, never replaced wholesale. See Plugins.md → Attaching plugin data (.meta). |
index | []string | Read-only, derived from meta keys that start with _ (meta._foo = "bar" → "meta#foo#bar") — same rules as Product's index. Filter it to resolve/enumerate orders by a hidden marker: sw.orders.list({ filters: { index: "meta#extid#A123" } }) (equality, standalone — no order, no composite index). |
fulfillment_ids | []integer | Absent when empty. |
manual ✎ | boolean | true = admin/POS/quote-composed (reserves no stock until paid). Absent when false. |
note ✎ | string | Internal admin note. Absent when empty. |
created_by_user_id | integer | Staff/rep who placed it on the customer's behalf; 0/absent for storefront self-checkout. Indexed (filterable in list). |
stock_reserved | boolean | Whether the order currently holds an inventory reservation. |
adjustments | []PaymentAdjustment | Plugin-contributed +/- total lines. Absent when empty. |
refunds | []OrderRefund | Absent when empty. |
OrderItem
| Field | Type | Notes |
|---|---|---|
product_id | integer | |
shop_id | integer | 0 = own product; >0 = supplier shop (wired). Absent when 0. |
name | string | |
sku | string | Absent when empty. |
price | integer | Unit price in cents. |
qty | integer | |
set | object<string,string> | Chosen variant options. Absent when empty. |
image | string | Absent when empty. |
plan | string | Subscription plan key; empty = one-time. |
OrderCustomer
{ "id": integer, "name": string, "email": string, "phone": string } — id/phone absent when empty; email is lowercased on save.
OrderPayment
| Field | Type | Notes |
|---|---|---|
provider | string | Gateway id (stripe, square, …); empty/manual = offline. |
method | string | Tender: card, cash, bank_transfer, ach, … Absent when empty. |
payment_id | string | Gateway payment id. |
status | string | pending, paid, failed, partially_refunded, refunded. The money truth for the order — the order's headline status is derived from it (together with the ship/cancel facts). |
OrderTotals
{ "subtotal", "tax", "shipping", "discount", "total" } — all integer cents. tax/shipping absent when 0.
OrderShippingMethod
| Field | Type | Notes |
|---|---|---|
id | string | Shop shipping-method id (for re-pricing). |
name | string | |
type | string | flat | weight | free | pickup. |
pickup | ShippingPickupDetails | Only for pickup. |
ShippingPickupDetails
{ "address": string, "instructions": string, "hours": string } — all optional.
Tracking
{ "carrier": string, "number": string, "url": string } — url optional.
PaymentAdjustment
{ "label": string, "amount": integer } — signed cents (negative = discount). Pushed by the payment.calculate_adjustment hook.
OrderRefund
| Field | Type | Notes |
|---|---|---|
id | string | Internal id; also the gateway idempotency key. |
amount | integer | Positive cents refunded. |
reason | string | Optional admin note. |
status | string | pending | succeeded | failed. |
refund_id | string | Provider refund id; empty for manual. |
manual | boolean | Recorded only; gateway not called. |
restock | boolean | |
items | []{ product_id, shop_id?, quantity } | Optional per-line breakdown. |
error | string | Gateway error when status == failed. |
created_by | string | Admin email. |
created_at | string | RFC3339. |
Customer
Returned by sw.customers.get/list; accepted by sw.customers.save (which
patches the stored customer). Also the shape
of ctx.data in customer.before_save / .after_save / .*_delete.
No
shop_id, no password. A customer's password and the internal shop-scoping id are never exposed to a plugin. Scoping is implicit (allsw.customersops run within the current shop).
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
created / updated | string | RFC3339, read-only. |
email ✎ | string | Required, unique per shop; lowercased/validated on save. |
name ✎ | string | |
type ✎ | string | lead (default) or customer. Other values fail the save. |
price_level ✎ | string | Names a price-level / product.prices key for B2B/tier pricing; empty = retail. Absent when empty. |
addresses ✎ | []Address | |
cart ✎ | []CartItem | The customer's saved cart. |
fields ✎ | object<string,string> | Custom fields — what the shopper filled in at signup, plus anything you or the store's staff add (they're visible and editable on the customer's admin page). Absent when empty, and a key you set to "" is removed rather than stored blank. A key beginning with _ also derives an index entry (below), which is how you keep a queryable key on a customer without hiding it from the merchant. At most 20 such keys are indexed, each up to 200 characters; a _ key can only be set by you, the admin or an import — never by the signup form. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable) — except keys beginning with _, which derive the queryable index field below. Merged by key on save, never replaced wholesale. See Plugins.md → Attaching plugin data (.meta). |
index | []string | Read-only, derived from meta and fields keys that start with _ — meta._foo = "bar" → "meta#foo#bar", fields._extid = "A123" → "fields#extid#A123". Filter it to resolve/enumerate customers by your own marker: sw.customers.list({ filters: { index: "fields#extid#A123" } }) (equality, standalone — no order, no composite index). Use meta for a key the merchant shouldn't see or edit, fields for one they should. |
alerts ✎ | { opt_out_all, categories, updated, source } | The customer's email preferences. opt_out_all silences every category except account/security mail. categories is an object of "<category key>": boolean holding only the choices that differ from the category default — an absent key means "whatever that category defaults to", so don't read it as "unsubscribed"; ask through a send instead. Keys are the store's own (orders, shipping, subscriptions, account, marketing) or a plugin's (plugin:<id>:<key>). updated is RFC3339 and source names where the last change came from (account, unsubscribe, checkout, admin, or a plugin id). Writing it directly is discouraged — send through sw.notify.customer, which applies these preferences for you. |
payment_method | { brand, last4, exp_month, exp_year, label } | Non-secret display hint for the saved method on file. A card sets brand/last4/expiry; a non-card method (Cash App Pay, Link, Amazon Pay, bank debit, …) has no card fields and sets a ready-to-show label (e.g. "Cash App Pay") instead — render label when present, else brand+last4. Read-only here (set via sw.customers.setPaymentMethod/clearPaymentMethod); the reusable token itself is never exposed. Absent when nothing is saved. |
payment_gateway | string | The gateway id (e.g. "stripe") that vaulted the saved method — matches your payment script's gateway_id. Charges against the saved method must go back to this gateway, so check customer.payment_gateway === "<your-gateway>" before offering "pay with saved method" (after a shop switches providers, a card vaulted by another gateway isn't yours to charge). Read-only; set alongside payment_method, absent when nothing is saved. |
anonymized | string|null | RFC3339 set when PII was erased (GDPR). Absent otherwise. |
CartItem
{ "product_id": integer, "shop_id": integer, "name": string, "price": integer, "image": string, "qty": integer, "set": object, "plan": string } — price in cents; set/plan/image optional.
Address
Shared by orders and customers.
{ "name", "line1", "line2", "city", "state", "zip", "country", "phone" } — all strings; line2/phone and any empty field are omitted. country is ISO 3166-1 alpha-2.
Coupon
Returned by sw.coupons.get/list; accepted by sw.coupons.save (which
patches the stored coupon). Also ctx.data in
coupon.before_save / .after_save / .*_delete.
Keyed by a numeric
id, not the code. To resolve a typed code, usesw.coupons.list({ filters: { code: "SAVE10" } }). The shop-scoping id is not exposed.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
code ✎ | string | Human code, unique per shop (renamable). |
created / updated | string | RFC3339, read-only. |
type ✎ | string | fixed or percent. |
value ✎ | integer | fixed: cents. percent: basis points (10000 = 100%). |
min_order ✎ | integer | Minimum order subtotal in cents. |
max_uses ✎ | integer | 0 = unlimited. |
uses | integer | Redemption count (platform-maintained). |
products ✎ | []integer | Restrict to these product ids. |
tags ✎ | []string | Restrict to products with these tags. |
attrs ✎ | []object | Restrict to products carrying an attribute: [{ name, value }]. An item qualifies if it matches any entry; omit value to match any value of that attribute. Matched against the product's attributes plus those of the line's selected variant. Combined with products/tags it narrows them (the item must satisfy both). name is required. Absent when empty. |
start ✎ | string|null | RFC3339 valid-from. Absent when unset. |
end ✎ | string|null | RFC3339 valid-until. Absent when unset. |
active ✎ | boolean | |
passive ✎ | boolean | Auto-apply at checkout. |
exclusive ✎ | boolean | Cannot combine with other coupons. |
featured ✎ | boolean | Shown on storefront. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable) — except keys beginning with _, which derive the queryable index field below. Absent when empty. Merged by key on save, never replaced wholesale. See Plugins.md → Attaching plugin data (.meta). |
index | []string | Read-only, derived from meta keys that start with _ (meta._foo = "bar" → "meta#foo#bar") — same rules as Product's index. Filter it to resolve/enumerate coupons by a hidden marker: sw.coupons.list({ filters: { index: "meta#extid#A123" } }) (equality, standalone — no order, no composite index). |
Subscription
A customer's recurring contract: what renews, how often, and where it stands. Each billing cycle mints a new order (the invoice) while this record persists across all of them.
Returned by sw.subscriptions.get/list. Fields marked ✎ are accepted by
sw.subscriptions.update — everything else is read-only, maintained by the
platform as the contract bills. There is no save/delete; see Plugins.md →
Subscriptions (sw.subscriptions).
| Field | Type | Notes |
|---|---|---|
id | integer | |
shop_id | integer | |
customer_id | integer | Who is billed. Not changeable. |
status | string | active, trialing (method saved, first charge deferred), past_due (a charge failed, retries in progress), paused, cancelled (final). Moved with pause/resume/cancel, not by update. |
gateway | string | Payment provider the contract was created with. |
interval ✎ | string | weekly | monthly | quarterly | yearly. |
anchor ✎ | string | Pins renewals to a calendar position instead of the signup anniversary: "" (anniversary), day_of_month, day_of_week. Must suit the interval — only a weekly contract can use day_of_week. Snapshotted from the plan at signup, so later edits to the product don't re-cadence existing customers. |
anchor_value ✎ | string | "1"–"31" or "last" for day_of_month; "mon"–"sun" for day_of_week. A day later than a given month has bills on that month's last day and returns to the intended day afterwards. |
next_bill_at ✎ | string | When the next charge is due. Must be in the future. |
cycle_count | integer | Successful renewals so far. |
max_cycles ✎ | integer | 0 = unlimited. Must exceed cycle_count. |
failure_count | integer | Consecutive declines; the contract is cancelled after 3. |
paused_until | string | Set when a pause has an end date. |
attempts | integer | Delivery attempts for the current cycle, including ones that never reached the provider. Distinct from failure_count, which counts declines across cycles. |
retry_at | string | When the next attempt is expected. |
last_error | SubscriptionError | Why the current cycle hasn't collected. Absent when nothing is wrong. |
hold | SubscriptionHold | Present when automatic billing has stopped and the merchant must intervene. Absent normally. |
items ✎ | []OrderItem | What renews. Supply quantities and prices; totals are recalculated for you. |
totals | OrderTotals | Read-only — derived from items, shipping, and the store's tax/shipping rules on every change. |
shipping ✎ | Address | Changing it re-prices shipping and tax for future renewals. |
shipping_method | object | Method captured at signup, re-priced when the address changes. |
currency | string | |
bounds | SubscriptionBounds | The range a cycle may move within, copied from the plan when the customer subscribed. Absent on a fixed-price contract. Not writable by a plugin — see below. |
max_cycle_amount | integer | The most a single cycle of this contract may ever bill, in cents, including tax and shipping — the total the customer agreed to when they subscribed. 0 on a fixed-price contract. Derived, never set directly. |
original_order_id | integer | The order the contract was created from. |
created / updated | string | |
meta | object | Free-form plugin storage. |
SubscriptionError
| Field | Type | Notes |
|---|---|---|
kind | string | decline — the charge was refused and the customer must act (update their payment method). system — the attempt itself failed and is being retried automatically; nothing for the customer to do. |
code | string | card_declined, no_payment_method, gateway_unavailable, internal, cycle_unresolved (working out what this cycle should bill failed; nothing was attempted against the payment method). |
message | string | Wording safe to show a shopper. |
at | string |
SubscriptionHold
| Field | Type | Notes |
|---|---|---|
reason | string | retries_exhausted — automatic retries ran out on failures outside the customer's control. cycle_missing — a variable contract reached its billing date with no amount decided for it. over_cap — the amount asked for falls outside the range agreed for this contract. |
message | string | |
set_by | string | |
at | string |
A hold is not a status: the contract keeps whatever status it had, stays intact, and is simply no longer scheduled. It resumes when the merchant releases it from the admin — the customer is never cancelled over a problem they couldn't fix.
Custom records
Records declared under a plugin's custom_records manifest entry, accessed via
sw.records.<type>.get/list/save/delete. Their shape differs from the built-ins.
A record is returned flattened — the declared fields sit at the top level
alongside the envelope keys, not nested under a data object:
const r = sw.records.demo_record.save({ title: "Hello", value: 123, enabled: true });
// r === {
// id: 42,
// kind: "demo_record",
// created: "2026-07-02T…",
// updated: "2026-07-02T…",
// title: "Hello", // ← declared fields, flat
// value: 123,
// enabled: true
// }
| Envelope field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
kind | string | The record type id (e.g. demo_record). Read-only. |
created / updated | string | RFC3339, read-only. |
| (declared fields) | per manifest | string / number / boolean / json per the custom_records[].fields[].type you declared. |
In record.<kind>.* hooks, ctx.data is this same flattened record.
Shop projection (ctx.shop)
Wherever a plugin is handed the shop — ctx.shop in routes/tasks, ctx.widget.shop
in widgets, and ctx.data.shop in the checkout/cart/payment hooks — it is an
allowlisted projection (only the fields below are exposed; everything else on the
shop is withheld):
| Field | Type |
|---|---|
id | integer |
name | string |
slogan | string |
subdomain | string |
domains | []string |
currency | string |
payment_provider | string |
canonical_host | string (computed) |
canonical_url | string (computed) |
passwordless_login / require_account | boolean |
logo_url | string |
color_primary, color_primary_hover, color_bg, color_surface, color_text_main, color_text_muted, color_error, color_success | string |
theme | object (theme config) |
auth | object (auth config) |
The ctx object
Hook ctx
Passed to every module.exports["<hook>"] = function (ctx) { … }:
| Field | Type | When present |
|---|---|---|
ctx.type | string | Always. The hook name, e.g. "order.after_save". |
ctx.data | object | Always. The hook payload — see the per-hook table. Mutating it in place is how you modify the entity (see below). |
ctx.old_data | object | Only on the CRUD *.before_save / *.after_save / *.before_delete / *.after_delete hooks — the pre-change entity. Absent otherwise. |
ctx.settings | object | Always. The plugin's merged settings ({} if none). |
ctx.plan | string | Always. Active plan key for this shop+plugin ("" = none). |
ctx.shop_id | integer | Always. |
ctx.dev | boolean | Always. true when the plugin is running as your live local copy during a dev session; false for an installed plugin. Guard production-only side effects with it (see below). |
ctx.request | object | Only for storefront-dispatched hooks — the request map. Absent for scheduled/webhook dispatches. |
ctx.timeoutRemaining() | function → integer | Always. Milliseconds left in the hook's budget (0 if exceeded). |
ctx.stop(reason?) | function | Always. Suppresses the platform default cleanly (see below). |
There is no
ctx.shoporctx.pluginon the generic hook ctx — onlyctx.shop_id. The shop object appears asctx.data.shopon the checkout/cart/payment hooks that include it (see the table).
What ctx.data holds per hook
The CRUD hooks carry the full entity (the shapes above) plus ctx.old_data. The
other hooks carry a purpose-built payload — the columns below name its shape; see the
linked Plugins.md sections for each hook's behavior and expected return.
Entity CRUD — ctx.data = the entity, with ctx.old_data:
| Hook family | ctx.data |
|---|---|
product.before_save / .after_save / .before_delete / .after_delete | Product |
order.before_save / .after_save / .before_delete / .after_delete | Order |
customer.before_save / .after_save / .before_delete / .after_delete | Customer |
coupon.before_save / .after_save / .before_delete / .after_delete | Coupon |
record.<kind>.before_save / .after_save / .before_delete / .after_delete | flattened custom record |
wired_fulfillment.before_save / .after_save / .before_delete / .after_delete | Fulfillment |
Commerce / calculation — see Plugins.md → Checkout & Cart Hooks and Payment Gateway Hooks:
The storefront cart→checkout hooks below (cart.calculate_prices, coupon.validate, shipping.calculate, tax.calculate, checkout.before_create) also receive ctx.customer — the logged-in shopper's Customer record (absent for guests) — so a calculation can vary by the signed-in customer (B2B price_level, saved-method payment_gateway, etc.). Secret fields are never exposed.
| Hook | ctx.data |
|---|---|
cart.calculate_prices | { items: [{ product_id, shop_id, name, set, qty, price }], shop } |
coupon.validate | { coupon, subtotal, cart } |
checkout.before_create | { order, cart, shop } |
checkout.after_payment | { order, provider, status } |
payment.before_intent | { order, shop } |
payment.calculate_adjustment | { order, shop, payment_method, adjustments: [] } → push { label, amount } |
payment.create_intent | { provider, shop, order, payment } |
payment.refund | { provider, order, amount, currency, reason, idempotency_key, payment_id } → set refund_id, status |
payment.webhook | { provider, body, headers } |
payment.webhook_account | { body, headers } → set account_id (runs with no sw.* bridges) |
shipping.calculate | { cart, weight, address, options: [] } → replace options (each option is { id, name, price, type, price_note?, pickup? }; set price_note to a short string like "—" to show that text instead of the price when the final amount is pending, so a price: 0 placeholder isn't mistaken for "Free") |
tax.calculate | { cart, subtotal, shipping, address } → set tax, name |
⚠️ For the
*.calculatehooks the engine reads back only your modifications toctx.data— you must assign to its fields (setctx.data.tax, replacectx.data.options); returning a value does nothing. To changeoptions, reassign the whole array —ctx.data.options = ctx.data.options.concat([newOpt])(or.filter(...), or[...ctx.data.options, newOpt]). A barectx.data.options.push(newOpt)is silently dropped (editing a field of an existing option in place, e.g.ctx.data.options[0].price = X, does take effect — only appending viapushis the trap). See Modifying vs. preventing.
Render / email / SEO / search — see Plugins.md → Template Render Hooks, Email Hooks, Sitemap & Robots Hooks, Search Provider Hooks:
| Hook | ctx.data |
|---|---|
template.before_render | { template, bindings } (+ ctx.customer = logged-in customer) |
email.marketing | { to, customer_id, category, category_label, class, subject, template_name, data, html, text } — marketing-class messages only, before the store's layout is applied. html/text is the message's own content; data carries unsubscribe_url / preferences_url / shop. stop() hands delivery to you and skips the platform's send entirely |
email.before_render | { to, subject, template_name, template, bindings, notify_category, notify_class } — stop() cancels the send |
email.send | { to, cc, bcc, reply_to, from, from_name, subject, html, text, notify_category, notify_class } |
sitemap.urls | { urls: [] } → return [{ loc, lastmod, changefreq, priority }] |
robots.txt | { lines: [] } → append strings |
search.query | { query, cursor, limit, own_only, sort, price_min, price_max, stream, filters?, facets?, count_accuracy } → set products: [{ id, shop_id }], cursor, result_count? |
search.index | { products: [<product maps>] } |
search.remove | { product_ids: [{ id, shop_id }] } |
search.drop | {} |
Lifecycle / async — see Plugins.md → Plugin Lifecycle Hooks, Container Job Hook, In-App Purchases:
| Hook | ctx.data |
|---|---|
plugin.activate / plugin.deactivate / plugin.uninstall | { plugin_id, version } |
plugin.change_version | { plugin_id, version, old_version } |
iap.purchase | { plugin_id, product_key, type, amount, credits, purchase_id, dev } |
container.job.completed | { job_id, status, exit_code, cost_cents, result_url, error } |
Modifying vs. preventing
- Modify an entity/payload by mutating
ctx.datain place. The engine diffsctx.databefore/after your handler and merges changed top-level keys back (last-writer-wins across plugins). Returning a value is ignored. - Prevent the platform's default action by
throw— either a string, or an objectthrow { error: "message", redirect_url: "/x" }(the structured throw is surfaced to the platform). This marks the event prevented and, for*.before_save, fails the operation. ctx.stop(reason?)is the clean alternative tothrow: "I've handled this, skip the built-in behaviour" — no error is logged. Use it for e.g.email.sendwhen your plugin delivered the mail itself.
Widget ctx
Passed to a widget's fetch(ctx) export (see Plugins.md → Dashboard Widgets):
| Field | Type | Notes |
|---|---|---|
ctx.request | object | The request map + body accessors. |
ctx.shop_id | integer | |
ctx.user_id | integer | Acting staff user. |
ctx.role | string | That user's shop role. |
ctx.permissions | []string | The plugin's granted permissions for this user. |
ctx.settings / ctx.plan / ctx.dev | object / string / boolean | As in hooks. |
ctx.widget | object | See below. |
ctx.widget:
| Field | Type | Notes |
|---|---|---|
id | string | Widget id. |
key | string | Dashboard placement key (empty for page widgets). |
config | object | Merchant-configured instance config (per config_defs). |
csrf | string | Token for the widget's own POSTs (sw-post / sw.fetch). |
page | boolean | |
dashboard_id | integer | Only when placed on a dashboard. |
placement | string | "dashboard" | "page" | "tab" | "button". |
entity | { type, id } | Detail-page widgets only — the bound record (e.g. { type: "order", id: 42 }). Load it via the matching sw.* bridge. |
user | { id, email, role, permissions } | When a user resolved. |
shop | object | The shop projection. |
base | string | Base path for the widget. |
url(p) | function → string | Builds a URL under base. |
ctx.request
The visitor request, exposed to fetch routes, widgets, and storefront-dispatched hooks. Sanitized — auth, cookie, and tracing headers are stripped, and only trusted, canonical request signals are exposed.
ctx.request = {
method: "GET",
url: "https://shop.example.com/product/x?ref=abc",
path: "/product/x",
proto: "https",
headers: { /* sanitized; see below */ },
query: { "ref": "abc" } // first value per key
}
Geo / IP / bot signals live inside headers (there is no top-level geo/ip
object), only populated in production:
| Header key | Meaning |
|---|---|
X-Real-Ip | Client IP. |
X-Geo-Country / X-Geo-Region / X-Geo-City / X-Geo-Postal / X-Geo-Latlong | Geo-IP (X-Geo-Latlong is one "lat,long" string). |
X-Bot-Score / X-Verified-Bot | Bot detection. |
Host | Canonical forwarded host. |
A theme sees these same values as the storefront geo binding, field for field
(Themes.md → "Visitor location").
Body (fetch routes + widgets only — hooks get no body accessors): ctx.request
also carries body (streaming reader), and text(), json(), arrayBuffer(),
formData(). The body is consume-once — buffering methods share a single read,
and streaming vs. buffering are mutually exclusive.
ctx.settings
The plugin's merged effective settings: the merchant's saved values overlaid on
the manifest settings[].defaults. So a setting the merchant never touched still
reads as its declared default. {} when the plugin declares no settings.
Caveat — bg tasks & lifecycle hooks get raw settings. In
sw.task.bgclosures, namedrun/action scripts, and theplugin.activate/deactivate/uninstallhooks,ctx.settingsis the saved settings only — manifest defaults are not merged in, so a defaulted-but-unsaved key can beundefined. Merge defaults yourself there, or read settings inside a hook/route/render path.
ctx.dev
true while the plugin runs as your live local copy during a dev session (the
code you're editing, synced from your machine); false once it's installed on a
shop from the marketplace. Present on every ctx — hooks, fetch routes, widgets,
lifecycle hooks, and background/scheduled runs.
Use it to skip a production-only side effect while testing — most commonly a step that's gated to marketplace installs and would error from a dev copy:
if (!ctx.dev) {
sw.payments.linkAccount(accountId); // marketplace-only; skip during local dev
}
Scheduled / background-task ctx
Scripts run outside the hook path (scheduled run, actions, sw.task.bg) also get a
ctx, plus the always-on ctx.settings / ctx.plan / ctx.shop_id / ctx.dev /
ctx.timeoutRemaining():
| Field | Type | Where |
|---|---|---|
ctx.args | any | sw.task.bg closure — the payload you enqueued (object, array, or literal), verbatim; undefined if none. |
ctx.params | object | Action scripts — the action param values. |
ctx.continue | { depth, data } | Continuation state for a re-enqueued task. |
ctx.shop | object | The shop projection, when in shop context. |