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 id to create).
  • Timestamps are RFC3339 strings (created, updated) set by the platform; they're read-only — writing them back on save() 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 with save().

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 sendResulting 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: nullcleared
no meta key at allunchanged

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.

FieldTypeNotes
idintegerOmit to create; set to update.
shop_idintegerThe owning shop. Read-only.
created / updatedstringRFC3339, read-only.
skustring
skus[]stringRead-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[]stringRead-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.
namestringRequired.
slugstringCustom storefront slug; "" = derive URL from name+id. Handleized + unique per shop on save; setting it 301-redirects the old slug. Absent when empty.
descstring
priceintegerSelling price in cents.
compare_priceintegerMSRP / strike-through price in cents.
pricesobject<string,integer>Named price tiers, e.g. { "wholesale": 1999 }. Keyed by a price-level / customer price_level name.
stockinteger
oversellbooleanAllow back-orders past stock.
weightnumberIn lbs.
images[]stringURLs.
tags[]string
activebooleanNew products default to active: true.
digitalbooleanDigital good (no shipping).
files[]stringDigital-download file paths.
options[]OptionOption dimensions (Size, Color).
variants[]VariantPer-combination SKU/price/stock.
attrs[]AttributeCustom attributes / facets.
price_tiers[]PriceTierQuantity price breaks.
subscriptionProductSubscriptionRecurring-purchase config; absent = one-time only.
metaobjectFree-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_setobject<string,string>Only present on a storefront-priced product (the chosen variant); not persisted.

Option

{ "name": "Size", "values": ["S", "M", "L"] }

Variant

FieldTypeNotes
setobject<string,string>The option combination, e.g. { "Size": "S", "Color": "Red" }.
skustring
priceinteger|nullnull = use base price.
compare_priceinteger|null
pricesobject<string,integer|null>Per-tier overrides.
stockinteger|nullnull = use base stock.
oversellboolean|nullnull = inherit product policy.
images[]string
attrs[]Attribute
price_tiers[]PriceTierEmpty = 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

FieldTypeNotes
enabledboolean
requiredbooleantrue = subscription-only (no one-time buy).
trial_daysinteger
max_cyclesinteger0 = unlimited.
plans[]SubscriptionPlan

SubscriptionPlan

FieldTypeNotes
keystringStable id stored on the order line (e.g. "monthly").
labelstringShown on the product page.
intervalstringweekly | monthly | quarterly | yearly.
priceinteger|nullFixed per-cycle cents; null = derive from product/variant price.
discountinteger% off base price when price is null.
first_cycle_discountinteger% off the first charge only.
variantobject<string,string>Option set this plan is scoped to; empty = all variants.
anchorstringPins renewals to a calendar position instead of each customer's signup date. "" (default) = signup anniversary, day_of_month, day_of_week.
anchor_valuestringThe 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_cyclestringWhat 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.
boundsSubscriptionBoundsPresent 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.

FieldTypeNotes
variablebooleantrue = 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_qtyintegerLowest quantity a cycle may bill. 0 means a cycle may bill nothing at all ("no lessons in August").
max_qtyintegerHighest quantity a cycle may bill. 0 = quantity is locked to what the contract already says.
min_priceinteger|nullLowest per-unit price in cents. null = a floor of 0.
max_priceinteger|nullHighest 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.

FieldTypeNotes
idintegerOmit to create.
shop_idintegerOwning shop. Read-only.
created / updatedstringRFC3339, read-only.
numberstringHuman order number. Auto-generated (unique per shop) if you don't set it; setting a duplicate fails the save.
statusstringOne 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.
currencystringAbsent when empty.
customerOrderCustomerSnapshot at order time.
shippingAddressShip-to address.
paymentOrderPayment
totalsOrderTotals
items[]OrderItemLine items.
shipping_methodOrderShippingMethodAbsent when unset.
trackings[]TrackingAbsent when empty.
shipped_atstringRFC3339; 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_atstringRFC3339; when the order was cancelled. Absent unless cancelled. Setting status to cancelled stamps it.
coupon_codes[]stringAbsent when empty.
tax_namestringLabel for the tax line. Absent when empty.
digital_onlybooleanComputed from items in before-save. Read-only.
reseller_shop_idintegerAbsent when 0.
subscription_idintegerLinks a renewal invoice to its contract; absent for one-time orders.
metaobjectFree-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[]stringRead-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[]integerAbsent when empty.
manualbooleantrue = admin/POS/quote-composed (reserves no stock until paid). Absent when false.
notestringInternal admin note. Absent when empty.
created_by_user_idintegerStaff/rep who placed it on the customer's behalf; 0/absent for storefront self-checkout. Indexed (filterable in list).
stock_reservedbooleanWhether the order currently holds an inventory reservation.
adjustments[]PaymentAdjustmentPlugin-contributed +/- total lines. Absent when empty.
refunds[]OrderRefundAbsent when empty.

OrderItem

FieldTypeNotes
product_idinteger
shop_idinteger0 = own product; >0 = supplier shop (wired). Absent when 0.
namestring
skustringAbsent when empty.
priceintegerUnit price in cents.
qtyinteger
setobject<string,string>Chosen variant options. Absent when empty.
imagestringAbsent when empty.
planstringSubscription 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

FieldTypeNotes
providerstringGateway id (stripe, square, …); empty/manual = offline.
methodstringTender: card, cash, bank_transfer, ach, … Absent when empty.
payment_idstringGateway payment id.
statusstringpending, 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

FieldTypeNotes
idstringShop shipping-method id (for re-pricing).
namestring
typestringflat | weight | free | pickup.
pickupShippingPickupDetailsOnly 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

FieldTypeNotes
idstringInternal id; also the gateway idempotency key.
amountintegerPositive cents refunded.
reasonstringOptional admin note.
statusstringpending | succeeded | failed.
refund_idstringProvider refund id; empty for manual.
manualbooleanRecorded only; gateway not called.
restockboolean
items[]{ product_id, shop_id?, quantity }Optional per-line breakdown.
errorstringGateway error when status == failed.
created_bystringAdmin email.
created_atstringRFC3339.

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 (all sw.customers ops run within the current shop).

FieldTypeNotes
idintegerOmit to create.
created / updatedstringRFC3339, read-only.
emailstringRequired, unique per shop; lowercased/validated on save.
namestring
typestringlead (default) or customer. Other values fail the save.
price_levelstringNames a price-level / product.prices key for B2B/tier pricing; empty = retail. Absent when empty.
addresses[]Address
cart[]CartItemThe customer's saved cart.
fieldsobject<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.
metaobjectFree-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[]stringRead-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_gatewaystringThe 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.
anonymizedstring|nullRFC3339 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, use sw.coupons.list({ filters: { code: "SAVE10" } }). The shop-scoping id is not exposed.

FieldTypeNotes
idintegerOmit to create.
codestringHuman code, unique per shop (renamable).
created / updatedstringRFC3339, read-only.
typestringfixed or percent.
valueintegerfixed: cents. percent: basis points (10000 = 100%).
min_orderintegerMinimum order subtotal in cents.
max_usesinteger0 = unlimited.
usesintegerRedemption count (platform-maintained).
products[]integerRestrict to these product ids.
tags[]stringRestrict to products with these tags.
attrs[]objectRestrict 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.
startstring|nullRFC3339 valid-from. Absent when unset.
endstring|nullRFC3339 valid-until. Absent when unset.
activeboolean
passivebooleanAuto-apply at checkout.
exclusivebooleanCannot combine with other coupons.
featuredbooleanShown on storefront.
metaobjectFree-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[]stringRead-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).

FieldTypeNotes
idinteger
shop_idinteger
customer_idintegerWho is billed. Not changeable.
statusstringactive, 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.
gatewaystringPayment provider the contract was created with.
intervalstringweekly | monthly | quarterly | yearly.
anchorstringPins 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_valuestring"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_atstringWhen the next charge is due. Must be in the future.
cycle_countintegerSuccessful renewals so far.
max_cyclesinteger0 = unlimited. Must exceed cycle_count.
failure_countintegerConsecutive declines; the contract is cancelled after 3.
paused_untilstringSet when a pause has an end date.
attemptsintegerDelivery attempts for the current cycle, including ones that never reached the provider. Distinct from failure_count, which counts declines across cycles.
retry_atstringWhen the next attempt is expected.
last_errorSubscriptionErrorWhy the current cycle hasn't collected. Absent when nothing is wrong.
holdSubscriptionHoldPresent when automatic billing has stopped and the merchant must intervene. Absent normally.
items[]OrderItemWhat renews. Supply quantities and prices; totals are recalculated for you.
totalsOrderTotalsRead-only — derived from items, shipping, and the store's tax/shipping rules on every change.
shippingAddressChanging it re-prices shipping and tax for future renewals.
shipping_methodobjectMethod captured at signup, re-priced when the address changes.
currencystring
boundsSubscriptionBoundsThe 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_amountintegerThe 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_idintegerThe order the contract was created from.
created / updatedstring
metaobjectFree-form plugin storage.

SubscriptionError

FieldTypeNotes
kindstringdecline — 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.
codestringcard_declined, no_payment_method, gateway_unavailable, internal, cycle_unresolved (working out what this cycle should bill failed; nothing was attempted against the payment method).
messagestringWording safe to show a shopper.
atstring

SubscriptionHold

FieldTypeNotes
reasonstringretries_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.
messagestring
set_bystring
atstring

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 fieldTypeNotes
idintegerOmit to create.
kindstringThe record type id (e.g. demo_record). Read-only.
created / updatedstringRFC3339, read-only.
(declared fields)per manifeststring / 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):

FieldType
idinteger
namestring
sloganstring
subdomainstring
domains[]string
currencystring
payment_providerstring
canonical_hoststring (computed)
canonical_urlstring (computed)
passwordless_login / require_accountboolean
logo_urlstring
color_primary, color_primary_hover, color_bg, color_surface, color_text_main, color_text_muted, color_error, color_successstring
themeobject (theme config)
authobject (auth config)

The ctx object

Hook ctx

Passed to every module.exports["<hook>"] = function (ctx) { … }:

FieldTypeWhen present
ctx.typestringAlways. The hook name, e.g. "order.after_save".
ctx.dataobjectAlways. The hook payload — see the per-hook table. Mutating it in place is how you modify the entity (see below).
ctx.old_dataobjectOnly on the CRUD *.before_save / *.after_save / *.before_delete / *.after_delete hooks — the pre-change entity. Absent otherwise.
ctx.settingsobjectAlways. The plugin's merged settings ({} if none).
ctx.planstringAlways. Active plan key for this shop+plugin ("" = none).
ctx.shop_idintegerAlways.
ctx.devbooleanAlways. 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.requestobjectOnly for storefront-dispatched hooks — the request map. Absent for scheduled/webhook dispatches.
ctx.timeoutRemaining()function → integerAlways. Milliseconds left in the hook's budget (0 if exceeded).
ctx.stop(reason?)functionAlways. Suppresses the platform default cleanly (see below).

There is no ctx.shop or ctx.plugin on the generic hook ctx — only ctx.shop_id. The shop object appears as ctx.data.shop on 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 CRUDctx.data = the entity, with ctx.old_data:

Hook familyctx.data
product.before_save / .after_save / .before_delete / .after_deleteProduct
order.before_save / .after_save / .before_delete / .after_deleteOrder
customer.before_save / .after_save / .before_delete / .after_deleteCustomer
coupon.before_save / .after_save / .before_delete / .after_deleteCoupon
record.<kind>.before_save / .after_save / .before_delete / .after_deleteflattened custom record
wired_fulfillment.before_save / .after_save / .before_delete / .after_deleteFulfillment

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.

Hookctx.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 *.calculate hooks the engine reads back only your modifications to ctx.data — you must assign to its fields (set ctx.data.tax, replace ctx.data.options); returning a value does nothing. To change options, reassign the whole arrayctx.data.options = ctx.data.options.concat([newOpt]) (or .filter(...), or [...ctx.data.options, newOpt]). A bare ctx.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 via push is the trap). See Modifying vs. preventing.

Render / email / SEO / search — see Plugins.md → Template Render Hooks, Email Hooks, Sitemap & Robots Hooks, Search Provider Hooks:

Hookctx.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:

Hookctx.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.data in place. The engine diffs ctx.data before/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 object throw { 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 to throw: "I've handled this, skip the built-in behaviour" — no error is logged. Use it for e.g. email.send when your plugin delivered the mail itself.

Widget ctx

Passed to a widget's fetch(ctx) export (see Plugins.md → Dashboard Widgets):

FieldTypeNotes
ctx.requestobjectThe request map + body accessors.
ctx.shop_idinteger
ctx.user_idintegerActing staff user.
ctx.rolestringThat user's shop role.
ctx.permissions[]stringThe plugin's granted permissions for this user.
ctx.settings / ctx.plan / ctx.devobject / string / booleanAs in hooks.
ctx.widgetobjectSee below.

ctx.widget:

FieldTypeNotes
idstringWidget id.
keystringDashboard placement key (empty for page widgets).
configobjectMerchant-configured instance config (per config_defs).
csrfstringToken for the widget's own POSTs (sw-post / sw.fetch).
pageboolean
dashboard_idintegerOnly when placed on a dashboard.
placementstring"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.
shopobjectThe shop projection.
basestringBase path for the widget.
url(p)function → stringBuilds 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 keyMeaning
X-Real-IpClient IP.
X-Geo-Country / X-Geo-Region / X-Geo-City / X-Geo-Postal / X-Geo-LatlongGeo-IP (X-Geo-Latlong is one "lat,long" string).
X-Bot-Score / X-Verified-BotBot detection.
HostCanonical 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.bg closures, named run/action scripts, and the plugin.activate/deactivate/ uninstall hooks, ctx.settings is the saved settings only — manifest defaults are not merged in, so a defaulted-but-unsaved key can be undefined. 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():

FieldTypeWhere
ctx.argsanysw.task.bg closure — the payload you enqueued (object, array, or literal), verbatim; undefined if none.
ctx.paramsobjectAction scripts — the action param values.
ctx.continue{ depth, data }Continuation state for a re-enqueued task.
ctx.shopobjectThe shop projection, when in shop context.