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