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, _erp_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#erp_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. A marker over 200 characters is left out of index — the meta value itself is stored and readable as always, it simply can't be resolved through a filter, and the save succeeds either way. Markers are for ids, codes and flags; put a note or a payload in a plain meta key.
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).
tax_exemptbooleanLeaves this product out of the taxable total wherever it is sold — groceries, medicine, gift cards, a service line. The rest of the basket is still rated normally. A discount reduces the taxable total only by the part that reaches taxable lines, worked out per coupon: one restricted to exempt products doesn't lower the tax at all, one restricted to taxable products comes off in full, and an unrestricted one is split across the basket by value. Stacked codes are attributed separately, so a code covering a taxable item outright still leaves nothing of it to tax when another code is applied alongside. Read live at every point tax is calculated, so flipping it takes effect immediately.
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.
offerobjectRead-only. The best automatic discount this product qualifies for, present only on a storefront read and absent when there is none. { code, type: "percent" | "fixed", percent, amount, min_order, ends }percent is the percentage off ready to print ("50", "12.5", empty for a fixed offer), amount the money off in cents, min_order the order minimum in cents (0 = none), ends when it stops (absent = open-ended). It advertises; price is unchanged, and the discount is worked out against the real cart at checkout. Not persisted. See Themes.md → Automatic discount offers.

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, ready_for_pickup, shipped, cancelled, refunded, partially_refunded, payment_failed. Any other value fails the save. Derived from the order's facts (payment.status, ready_at, shipped_at, cancelled_at); writing a status performs the matching action — shipped records the shipment (stamps shipped_at), ready_for_pickup records that a pickup order is waiting at the counter (stamps ready_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. ready_for_pickup on an order that isn't collected in store fails the save.
currencystringAbsent when empty.
customerOrderCustomerSnapshot at order time.
shippingAddressShip-to address.
paymentOrderPayment
totalsOrderTotals
items[]OrderItemLine items.
shipping_methodOrderShippingMethodAbsent when unset.
trackings[]TrackingAbsent when empty.
ready_atstringRFC3339; when a store-pickup order was set aside at the counter for the customer to collect. Absent until then, and only orders the customer collects in store may carry it — writing it on any other order fails the save. Setting status to ready_for_pickup stamps it; recording the handover (shipped) outranks it.
shipped_atstringRFC3339; when the order was shipped — or, for a pickup order, collected. Absent until then. 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.
fieldsobject<string,string>The extra details checkout asked the shopper for (a phone number, a PO number, delivery instructions…), keyed by the handleized label the merchant configured — "PO Number" arrives as po-number. A snapshot of what was asked on this order, so it stays accurate after the merchant changes the questions. Absent when empty; blank answers aren't stored. Unlike meta it derives no index entries — the values are typed by the shopper. A collected phone also lands on customer.phone.
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.
tax_exemptbooleanThis line was excluded from the taxable total, copied from the product when the order was built. Absent when false. Kept on the order so a receipt or a refund can show why the tax is what it is even after the product changes.

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, "location": Address, "instructions": string, "hours": string } — all optional.

address is the one-line form shown to the shopper (checkout, the ready-for-pickup email). location is the same counter as a structured Addressline1, line2, city, state, zip, country — and it is what decides tax: an order collected in store has no delivery address, so its tax is rated at the counter. A location needs at least a country to be used; without one the store falls back to the shopper's address, and a store that filled in nothing but address keeps behaving exactly as before.

Leaving address blank fills it in from location on save, so a store only has to enter the place once. Set it to override the wording shown to shoppers.

Tracking

{ "carrier": string, "number": string, "url": string }url optional.

Leave url out and a major carrier (USPS, UPS, FedEx, DHL, Canada Post, Purolator, Royal Mail, Australia Post, OnTrac, DPD, GLS, NZ Post) gets its standard tracking link, matched on the carrier name however it is capitalized or spaced. So url is filled in whenever the carrier is one of those, and what you write to it is an override: set it for a carrier not on that list, or to point somewhere other than the carrier's own page. An override you save comes back exactly as written; the standard link is worked out fresh each read, so a carrier changing its tracking address doesn't leave old shipments pointing at a dead page.

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.
exclude_products[]integerNever discount these product ids. Applied after products/tags/attrs and outranking them, so an excluded product stays out however it qualified — "everything tagged sale except this one". Also suppresses the product's offer badge, so nothing is advertised that the checkout would refuse. Listing the same id in both products and exclude_products is rejected on save (it could only ever discount nothing). Duplicates are collapsed; absent when empty.
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.
price_levels[]stringRestrict the coupon to shoppers on these customer price levels. Absent/empty = every shopper. "" is the regular price — a guest, or a customer with no level — because that is the value Customer.price_level holds for them; so ["", "wholesale"] means "regular and wholesale customers, nobody else". A shopper outside the list is refused the code and never shown the offer.
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.