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).

Product

Returned by sw.products.get/list/search; accepted by sw.products.save. 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
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). 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.

Order

Returned by sw.orders.get/list; accepted by sw.orders.save (which merges onto 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.
currencystringAbsent when empty.
customerOrderCustomerSnapshot at order time.
shippingAddressShip-to address.
paymentOrderPayment
totalsOrderTotals
items[]OrderItemLine items.
shipping_methodOrderShippingMethodAbsent when unset.
trackings[]TrackingAbsent when empty.
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).
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, refunded.

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. 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 form fields. Absent when empty.
metaobjectFree-form plugin storage (opaque, not queryable).
alerts{ opt_out_all: boolean }Notification opt-out.
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. 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.
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.

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

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
tax.calculate{ cart, subtotal, shipping, address } → set tax, name

⚠️ For the *.calculate hooks the engine reads back only your modifications to ctx.data (the diff), so you must mutate the payload's options / tax in place — returning a value does nothing. 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.before_render{ to, subject, template_name, template, bindings }stop() cancels the send
email.send{ to, cc, bcc, reply_to, from, from_name, subject, html, text }
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, filters?, facets? } → set products: [{ id, shop_id }]
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.planobject / stringAs 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-Bot-Score / X-Verified-BotBot detection.
HostCanonical forwarded host.

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.

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.timeoutRemaining():

FieldTypeWhere
ctx.argsarraysw.task.bg closure — the args you enqueued.
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.