Entity & Context Reference
This is the field-level companion to Plugins.md. Plugins.md documents
what the bridges and hooks do; this document documents the exact shape of the
data they hand you — the entity records returned by sw.products / sw.orders /
sw.customers / sw.coupons / sw.records, and the ctx object your hooks,
widgets, routes, and tasks receive.
Conventions used throughout:
- Empty fields are omitted. When a field is empty it is absent from the record
entirely — don't assume a key exists. Read defensively with optional chaining or
defaults:
const tags = product.tags || []. - Some fields are never exposed to plugins — a customer's password and the
internal shop-scoping id are invisible. Scoping is implicit (every
sw.*op runs within the current shop), so you never set or read it. - Money is integer cents (
price: 2499= $24.99). Never floats. - Ids are integers and auto-allocated on first save (pass no
idto create). - Timestamps are RFC3339 strings (
created,updated) set by the platform; they're read-only — writing them back onsave()has no effect. - A ✎ marks a field you can set via
save(). Unmarked fields are platform-managed (read-only, or computed in a before-save hook).
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.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create; set to update. |
shop_id | integer | The owning shop. Read-only. |
created / updated | string | RFC3339, read-only. |
sku ✎ | string | |
name ✎ | string | Required. |
slug ✎ | string | Custom storefront slug; "" = derive URL from name+id. Handleized + unique per shop on save; setting it 301-redirects the old slug. Absent when empty. |
desc ✎ | string | |
price ✎ | integer | Selling price in cents. |
compare_price ✎ | integer | MSRP / strike-through price in cents. |
prices ✎ | object<string,integer> | Named price tiers, e.g. { "wholesale": 1999 }. Keyed by a price-level / customer price_level name. |
stock ✎ | integer | |
oversell ✎ | boolean | Allow back-orders past stock. |
weight ✎ | number | In lbs. |
images ✎ | []string | URLs. |
tags ✎ | []string | |
active ✎ | boolean | New products default to active: true. |
digital ✎ | boolean | Digital good (no shipping). |
files ✎ | []string | Digital-download file paths. |
options ✎ | []Option | Option dimensions (Size, Color). |
variants ✎ | []Variant | Per-combination SKU/price/stock. |
attrs ✎ | []Attribute | Custom attributes / facets. |
price_tiers ✎ | []PriceTier | Quantity price breaks. |
subscription ✎ | ProductSubscription | Recurring-purchase config; absent = one-time only. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable). See Plugins.md → Attaching plugin data (.meta). |
selected_set | object<string,string> | Only present on a storefront-priced product (the chosen variant); not persisted. |
Option
{ "name": "Size", "values": ["S", "M", "L"] }
Variant
| Field | Type | Notes |
|---|---|---|
set | object<string,string> | The option combination, e.g. { "Size": "S", "Color": "Red" }. |
sku | string | |
price | integer|null | null = use base price. |
compare_price | integer|null | |
prices | object<string,integer|null> | Per-tier overrides. |
stock | integer|null | null = use base stock. |
oversell | boolean|null | null = inherit product policy. |
images | []string | |
attrs | []Attribute | |
price_tiers | []PriceTier | Empty = use product-level tiers. |
PriceTier
{ "min_qty": 10, "price": 1999 } — applies when ordered qty ≥ min_qty; the highest matching min_qty wins. price in cents.
Attribute
{ "name": "Material", "value": "Cotton", "extra": { "facet": true } } — extra is optional (facet, hidden, …).
ProductSubscription
| Field | Type | Notes |
|---|---|---|
enabled | boolean | |
required | boolean | true = subscription-only (no one-time buy). |
trial_days | integer | |
max_cycles | integer | 0 = unlimited. |
plans | []SubscriptionPlan |
SubscriptionPlan
| Field | Type | Notes |
|---|---|---|
key | string | Stable id stored on the order line (e.g. "monthly"). |
label | string | Shown on the product page. |
interval | string | weekly | monthly | quarterly | yearly. |
price | integer|null | Fixed per-cycle cents; null = derive from product/variant price. |
discount | integer | % off base price when price is null. |
first_cycle_discount | integer | % off the first charge only. |
variant | object<string,string> | Option set this plan is scoped to; empty = all variants. |
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.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
shop_id | integer | Owning shop. Read-only. |
created / updated | string | RFC3339, read-only. |
number ✎ | string | Human order number. Auto-generated (unique per shop) if you don't set it; setting a duplicate fails the save. |
status ✎ | string | One of created, processing, shipped, cancelled, refunded, partially_refunded, payment_failed. Any other value fails the save. |
currency ✎ | string | Absent when empty. |
customer ✎ | OrderCustomer | Snapshot at order time. |
shipping ✎ | Address | Ship-to address. |
payment ✎ | OrderPayment | |
totals ✎ | OrderTotals | |
items ✎ | []OrderItem | Line items. |
shipping_method ✎ | OrderShippingMethod | Absent when unset. |
trackings ✎ | []Tracking | Absent when empty. |
coupon_codes ✎ | []string | Absent when empty. |
tax_name ✎ | string | Label for the tax line. Absent when empty. |
digital_only | boolean | Computed from items in before-save. Read-only. |
reseller_shop_id | integer | Absent when 0. |
subscription_id | integer | Links a renewal invoice to its contract; absent for one-time orders. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable). |
fulfillment_ids | []integer | Absent when empty. |
manual ✎ | boolean | true = admin/POS/quote-composed (reserves no stock until paid). Absent when false. |
note ✎ | string | Internal admin note. Absent when empty. |
created_by_user_id | integer | Staff/rep who placed it on the customer's behalf; 0/absent for storefront self-checkout. Indexed (filterable in list). |
stock_reserved | boolean | Whether the order currently holds an inventory reservation. |
adjustments | []PaymentAdjustment | Plugin-contributed +/- total lines. Absent when empty. |
refunds | []OrderRefund | Absent when empty. |
OrderItem
| Field | Type | Notes |
|---|---|---|
product_id | integer | |
shop_id | integer | 0 = own product; >0 = supplier shop (wired). Absent when 0. |
name | string | |
sku | string | Absent when empty. |
price | integer | Unit price in cents. |
qty | integer | |
set | object<string,string> | Chosen variant options. Absent when empty. |
image | string | Absent when empty. |
plan | string | Subscription plan key; empty = one-time. |
OrderCustomer
{ "id": integer, "name": string, "email": string, "phone": string } — id/phone absent when empty; email is lowercased on save.
OrderPayment
| Field | Type | Notes |
|---|---|---|
provider | string | Gateway id (stripe, square, …); empty/manual = offline. |
method | string | Tender: card, cash, bank_transfer, ach, … Absent when empty. |
payment_id | string | Gateway payment id. |
status | string | pending, paid, failed, refunded. |
OrderTotals
{ "subtotal", "tax", "shipping", "discount", "total" } — all integer cents. tax/shipping absent when 0.
OrderShippingMethod
| Field | Type | Notes |
|---|---|---|
id | string | Shop shipping-method id (for re-pricing). |
name | string | |
type | string | flat | weight | free | pickup. |
pickup | ShippingPickupDetails | Only for pickup. |
ShippingPickupDetails
{ "address": string, "instructions": string, "hours": string } — all optional.
Tracking
{ "carrier": string, "number": string, "url": string } — url optional.
PaymentAdjustment
{ "label": string, "amount": integer } — signed cents (negative = discount). Pushed by the payment.calculate_adjustment hook.
OrderRefund
| Field | Type | Notes |
|---|---|---|
id | string | Internal id; also the gateway idempotency key. |
amount | integer | Positive cents refunded. |
reason | string | Optional admin note. |
status | string | pending | succeeded | failed. |
refund_id | string | Provider refund id; empty for manual. |
manual | boolean | Recorded only; gateway not called. |
restock | boolean | |
items | []{ product_id, shop_id?, quantity } | Optional per-line breakdown. |
error | string | Gateway error when status == failed. |
created_by | string | Admin email. |
created_at | string | RFC3339. |
Customer
Returned by sw.customers.get/list; accepted by sw.customers.save. Also the shape
of ctx.data in customer.before_save / .after_save / .*_delete.
No
shop_id, no password. A customer's password and the internal shop-scoping id are never exposed to a plugin. Scoping is implicit (allsw.customersops run within the current shop).
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
created / updated | string | RFC3339, read-only. |
email ✎ | string | Required, unique per shop; lowercased/validated on save. |
name ✎ | string | |
type ✎ | string | lead (default) or customer. Other values fail the save. |
price_level ✎ | string | Names a price-level / product.prices key for B2B/tier pricing; empty = retail. Absent when empty. |
addresses ✎ | []Address | |
cart ✎ | []CartItem | The customer's saved cart. |
fields ✎ | object<string,string> | Custom form fields. Absent when empty. |
meta ✎ | object | Free-form plugin storage (opaque, not queryable). |
alerts ✎ | { opt_out_all: boolean } | Notification opt-out. |
anonymized | string|null | RFC3339 set when PII was erased (GDPR). Absent otherwise. |
CartItem
{ "product_id": integer, "shop_id": integer, "name": string, "price": integer, "image": string, "qty": integer, "set": object, "plan": string } — price in cents; set/plan/image optional.
Address
Shared by orders and customers.
{ "name", "line1", "line2", "city", "state", "zip", "country", "phone" } — all strings; line2/phone and any empty field are omitted. country is ISO 3166-1 alpha-2.
Coupon
Returned by sw.coupons.get/list; accepted by sw.coupons.save. Also ctx.data in
coupon.before_save / .after_save / .*_delete.
Keyed by a numeric
id, not the code. To resolve a typed code, usesw.coupons.list({ filters: { code: "SAVE10" } }). The shop-scoping id is not exposed.
| Field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
code ✎ | string | Human code, unique per shop (renamable). |
created / updated | string | RFC3339, read-only. |
type ✎ | string | fixed or percent. |
value ✎ | integer | fixed: cents. percent: basis points (10000 = 100%). |
min_order ✎ | integer | Minimum order subtotal in cents. |
max_uses ✎ | integer | 0 = unlimited. |
uses | integer | Redemption count (platform-maintained). |
products ✎ | []integer | Restrict to these product ids. |
tags ✎ | []string | Restrict to products with these tags. |
start ✎ | string|null | RFC3339 valid-from. Absent when unset. |
end ✎ | string|null | RFC3339 valid-until. Absent when unset. |
active ✎ | boolean | |
passive ✎ | boolean | Auto-apply at checkout. |
exclusive ✎ | boolean | Cannot combine with other coupons. |
featured ✎ | boolean | Shown on storefront. |
Custom records
Records declared under a plugin's custom_records manifest entry, accessed via
sw.records.<type>.get/list/save/delete. Their shape differs from the built-ins.
A record is returned flattened — the declared fields sit at the top level
alongside the envelope keys, not nested under a data object:
const r = sw.records.demo_record.save({ title: "Hello", value: 123, enabled: true });
// r === {
// id: 42,
// kind: "demo_record",
// created: "2026-07-02T…",
// updated: "2026-07-02T…",
// title: "Hello", // ← declared fields, flat
// value: 123,
// enabled: true
// }
| Envelope field | Type | Notes |
|---|---|---|
id | integer | Omit to create. |
kind | string | The record type id (e.g. demo_record). Read-only. |
created / updated | string | RFC3339, read-only. |
| (declared fields) | per manifest | string / number / boolean / json per the custom_records[].fields[].type you declared. |
In record.<kind>.* hooks, ctx.data is this same flattened record.
Shop projection (ctx.shop)
Wherever a plugin is handed the shop — ctx.shop in routes/tasks, ctx.widget.shop
in widgets, and ctx.data.shop in the checkout/cart/payment hooks — it is an
allowlisted projection (only the fields below are exposed; everything else on the
shop is withheld):
| Field | Type |
|---|---|
id | integer |
name | string |
slogan | string |
subdomain | string |
domains | []string |
currency | string |
payment_provider | string |
canonical_host | string (computed) |
canonical_url | string (computed) |
passwordless_login / require_account | boolean |
logo_url | string |
color_primary, color_primary_hover, color_bg, color_surface, color_text_main, color_text_muted, color_error, color_success | string |
theme | object (theme config) |
auth | object (auth config) |
The ctx object
Hook ctx
Passed to every module.exports["<hook>"] = function (ctx) { … }:
| Field | Type | When present |
|---|---|---|
ctx.type | string | Always. The hook name, e.g. "order.after_save". |
ctx.data | object | Always. The hook payload — see the per-hook table. Mutating it in place is how you modify the entity (see below). |
ctx.old_data | object | Only on the CRUD *.before_save / *.after_save / *.before_delete / *.after_delete hooks — the pre-change entity. Absent otherwise. |
ctx.settings | object | Always. The plugin's merged settings ({} if none). |
ctx.plan | string | Always. Active plan key for this shop+plugin ("" = none). |
ctx.shop_id | integer | Always. |
ctx.request | object | Only for storefront-dispatched hooks — the request map. Absent for scheduled/webhook dispatches. |
ctx.timeoutRemaining() | function → integer | Always. Milliseconds left in the hook's budget (0 if exceeded). |
ctx.stop(reason?) | function | Always. Suppresses the platform default cleanly (see below). |
There is no
ctx.shoporctx.pluginon the generic hook ctx — onlyctx.shop_id. The shop object appears asctx.data.shopon the checkout/cart/payment hooks that include it (see the table).
What ctx.data holds per hook
The CRUD hooks carry the full entity (the shapes above) plus ctx.old_data. The
other hooks carry a purpose-built payload — the columns below name its shape; see the
linked Plugins.md sections for each hook's behavior and expected return.
Entity CRUD — ctx.data = the entity, with ctx.old_data:
| Hook family | ctx.data |
|---|---|
product.before_save / .after_save / .before_delete / .after_delete | Product |
order.before_save / .after_save / .before_delete / .after_delete | Order |
customer.before_save / .after_save / .before_delete / .after_delete | Customer |
coupon.before_save / .after_save / .before_delete / .after_delete | Coupon |
record.<kind>.before_save / .after_save / .before_delete / .after_delete | flattened custom record |
wired_fulfillment.before_save / .after_save / .before_delete / .after_delete | Fulfillment |
Commerce / calculation — see Plugins.md → Checkout & Cart Hooks and Payment Gateway Hooks:
| Hook | ctx.data |
|---|---|
cart.calculate_prices | { items: [{ product_id, shop_id, name, set, qty, price }], shop } |
coupon.validate | { coupon, subtotal, cart } |
checkout.before_create | { order, cart, shop } |
checkout.after_payment | { order, provider, status } |
payment.before_intent | { order, shop } |
payment.calculate_adjustment | { order, shop, payment_method, adjustments: [] } → push { label, amount } |
payment.create_intent | { provider, shop, order, payment } |
payment.refund | { provider, order, amount, currency, reason, idempotency_key, payment_id } → set refund_id, status |
payment.webhook | { provider, body, headers } |
payment.webhook_account | { body, headers } → set account_id (runs with no sw.* bridges) |
shipping.calculate | { cart, weight, address, options: [] } → replace options |
tax.calculate | { cart, subtotal, shipping, address } → set tax, name |
⚠️ For the
*.calculatehooks the engine reads back only your modifications toctx.data(the diff), so you must mutate the payload'soptions/taxin 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:
| Hook | ctx.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:
| Hook | ctx.data |
|---|---|
plugin.activate / plugin.deactivate / plugin.uninstall | { plugin_id, version } |
plugin.change_version | { plugin_id, version, old_version } |
iap.purchase | { plugin_id, product_key, type, amount, credits, purchase_id, dev } |
container.job.completed | { job_id, status, exit_code, cost_cents, result_url, error } |
Modifying vs. preventing
- Modify an entity/payload by mutating
ctx.datain place. The engine diffsctx.databefore/after your handler and merges changed top-level keys back (last-writer-wins across plugins). Returning a value is ignored. - Prevent the platform's default action by
throw— either a string, or an objectthrow { error: "message", redirect_url: "/x" }(the structured throw is surfaced to the platform). This marks the event prevented and, for*.before_save, fails the operation. ctx.stop(reason?)is the clean alternative tothrow: "I've handled this, skip the built-in behaviour" — no error is logged. Use it for e.g.email.sendwhen your plugin delivered the mail itself.
Widget ctx
Passed to a widget's fetch(ctx) export (see Plugins.md → Dashboard Widgets):
| Field | Type | Notes |
|---|---|---|
ctx.request | object | The request map + body accessors. |
ctx.shop_id | integer | |
ctx.user_id | integer | Acting staff user. |
ctx.role | string | That user's shop role. |
ctx.permissions | []string | The plugin's granted permissions for this user. |
ctx.settings / ctx.plan | object / string | As in hooks. |
ctx.widget | object | See below. |
ctx.widget:
| Field | Type | Notes |
|---|---|---|
id | string | Widget id. |
key | string | Dashboard placement key (empty for page widgets). |
config | object | Merchant-configured instance config (per config_defs). |
csrf | string | Token for the widget's own POSTs (sw-post / sw.fetch). |
page | boolean | |
dashboard_id | integer | Only when placed on a dashboard. |
placement | string | "dashboard" | "page" | "tab" | "button". |
entity | { type, id } | Detail-page widgets only — the bound record (e.g. { type: "order", id: 42 }). Load it via the matching sw.* bridge. |
user | { id, email, role, permissions } | When a user resolved. |
shop | object | The shop projection. |
base | string | Base path for the widget. |
url(p) | function → string | Builds a URL under base. |
ctx.request
The visitor request, exposed to fetch routes, widgets, and storefront-dispatched hooks. Sanitized — auth, cookie, and tracing headers are stripped, and only trusted, canonical request signals are exposed.
ctx.request = {
method: "GET",
url: "https://shop.example.com/product/x?ref=abc",
path: "/product/x",
proto: "https",
headers: { /* sanitized; see below */ },
query: { "ref": "abc" } // first value per key
}
Geo / IP / bot signals live inside headers (there is no top-level geo/ip
object), only populated in production:
| Header key | Meaning |
|---|---|
X-Real-Ip | Client IP. |
X-Geo-Country / X-Geo-Region / X-Geo-City / X-Geo-Postal / X-Geo-Latlong | Geo-IP. |
X-Bot-Score / X-Verified-Bot | Bot detection. |
Host | Canonical 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.bgclosures, namedrun/action scripts, and theplugin.activate/deactivate/uninstallhooks,ctx.settingsis the saved settings only — manifest defaults are not merged in, so a defaulted-but-unsaved key can beundefined. Merge defaults yourself there, or read settings inside a hook/route/render path.
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():
| Field | Type | Where |
|---|---|---|
ctx.args | array | sw.task.bg closure — the args you enqueued. |
ctx.params | object | Action scripts — the action param values. |
ctx.continue | { depth, data } | Continuation state for a re-enqueued task. |
ctx.shop | object | The shop projection, when in shop context. |