@sixtyninecommerce/admin
v0.18.0
Published
Back-office SDK for sixtynine-commerce: every capability of the platform as a method
Readme
@sixtyninecommerce/admin
The back-office SDK for sixtynine-commerce. Every capability of the platform is a method here — no raw GraphQL, no REST paths, no header names.
Built on @sixtyninecommerce/core, which
gives it the tenant context, the error hierarchy, cursor pagination, idempotency keys and retries.
Install
npm install @sixtyninecommerce/adminUsage
import { createAdminClient } from '@sixtyninecommerce/admin';
const admin = createAdminClient({
urls: {
vendureUrl: 'https://engine.sixtyninecommerce.com',
cmsUrl: 'https://cms.sixtyninecommerce.com',
pluginsUrl: 'https://plugins.sixtyninecommerce.com',
},
auth: { token: process.env.SIXTYNINE_API_TOKEN! },
tenant: { scope: { kind: 'channel', code: 'velvet-thread' } },
});
for await (const form of await admin.content.forms.list()) {
console.log(form.slug);
}A smaller bundle: only the domains you use
createAdminClient builds every domain of the platform. A bundler that tree-shakes drops the code
you never call, but React Native's Metro does not: an app that imports the package entry ships all
of it (about 2 MB of Hermes bytecode). Such an app composes its client from the domains it uses
instead. Each domain is its own entry, and @sixtyninecommerce/admin/compose builds the client
from a list:
import { composeAdminClient, PlatformError } from '@sixtyninecommerce/admin/compose';
import { ordersDomain, ORDER_INCLUDES } from '@sixtyninecommerce/admin/orders';
import { reportsDomain } from '@sixtyninecommerce/admin/reports';
import { tenantDomain } from '@sixtyninecommerce/admin/tenant';
import type { Order } from '@sixtyninecommerce/admin';
const admin = composeAdminClient({ urls, auth: { session }, channels }, [
ordersDomain,
reportsDomain,
tenantDomain,
]);
await admin.orders.get(id, { include: ORDER_INCLUDES });
await admin.tenant.timeZone();- The client takes the same configuration as
createAdminClientand refuses what it refuses. It always hasauth,channels,withTenantandrateLimit, plus the members its domains build, typed and behaving exactly as on the full client. A method's path is the same, soMETHOD_EFFECTS['orders.settle']describesadmin.orders.settleeither way.AdminClientOf<typeof DOMAINS>names the client type for a list. - There is one entry per member of the client, named after its directory:
@sixtyninecommerce/admin/ordersexportsordersDomain,/api-tokensexportsapiTokensDomain,/stored-valueexportsstoredValueDomain. Each also exports the values the package entry exports from that domain (ORDER_INCLUDES,QUOTE_STATUSES,decodePdf, …). composeexports the values the package entry re-exports from core: the error classes,createChannelListResolver,mintTraceparent,offsetCursor, the event helpers andAdminProjectionError.- Import types from the package entry with
import type. A type import loads nothing. A VALUE imported from@sixtyninecommerce/adminloads every domain again. - An entry loads only its own domain and what that domain is built on.
reportsbuilds onbulk, which builds onuploadsandjobs; the client builds each of those once and shares it, whether or not you list them too. tenantandintegrationsalso come in parts, one per member:@sixtyninecommerce/admin/tenant/time-zoneexportstenantTimeZoneDomain, which buildsadmin.tenant.timeZoneand nothing else oftenant, and@sixtyninecommerce/admin/integrations/exact-onlineexportsintegrationsExactOnlineDomain. The client's type has exactly the members its parts built. A part composed beside its whole domain adds nothing.
pnpm --filter @sixtyninecommerce/admin bundle:size ./compose ./orders … prints what a set of
entries costs, minified and module by module, as Metro ships them.
Credentials
| Principal | How | Where |
|---|---|---|
| API token | auth: { token: 's69t_…' } | server only — constructing one in a browser throws CREDENTIAL_IN_BROWSER |
| Admin session | auth: { session: { token?, onToken } } | server or browser |
| App installation | auth: { app: { appId, kid, privateKey }, installationId } | server only — a private key in a browser throws CREDENTIAL_IN_BROWSER |
A session client can start empty and sign in:
const session: { token?: string } = {};
const admin = createAdminClient({ urls, auth: { session } });
const me = await admin.auth.login('[email protected]', 'secret');
const tenant = admin.withTenant({ scope: { kind: 'channel', code: me.channels[0]!.code } });Each of me.channels carries channelKind — 'hub' for an organization's workspace, which is
never a store, anything else a store — and organizationId, the organization a store belongs to or
whose workspace a hub is (null for neither).
me.administrator is the person behind the session: name and email address, roles (every role
they hold, across all channels), lastLogin (ISO 8601, null when never recorded) and
profilePhotoUrl (this platform's Administrator custom field: a storage key to resolve against
the CDN, or an absolute URL; null when none). It is null for a user with no administrator record.
auth.login, auth.me and auth.logout go out with no store scope, even on a client
withTenant scoped: a session belongs to the person, not to a store. A store the client was
scoped to can be closed (CHANNEL_SOFT_DELETED, 403) or deleted (CHANNEL_NOT_FOUND, 404) while
the session lives on; auth.me() still answers, and its channels say which stores remain.
The session object you pass IS where the bearer is stored. Vendure returns a fresh one on a
response header and the SDK writes it back into session.token, so it must be writable — a plain
property, or a getter with a setter over your own store. A read-only one is refused at construction
with SESSION_NOT_WRITABLE rather than dropping every rotation silently. onToken is a
notification for persisting it across processes, and it fires with '' when logout() ends the
session so a stored copy can be invalidated.
In a server, give each request its own session object — one shared object is one shared credential:
const admin = createAdminClient({
urls,
auth: {
session: {
get token() { return requestContext.token; },
set token(next: string | undefined) { requestContext.token = next; },
},
},
});A bearer is not proof of a session: Vendure mints an anonymous one for an unauthenticated read.
Treat "signed in" as "auth.me() resolved", never as "a token is stored".
What an API token cannot do
apiTokens.* and organization.apiTokens.* need a human session. No token, a store's or an
organization's, may hold ManageApiToken, InstallApps, ManageBilling, SuperAdmin, Owner,
ManageOrganization or ApproveSeoChanges: a mint or edit naming one is refused
(PermissionDeniedError, TOKEN_PERMISSION_NOT_ALLOWED), so a token can never manage tokens —
including its own — nor install, consent to, suspend or uninstall an app, change the platform
billing, administer an organization or approve an SEO proposal, and platform.* refuses a token
in the SDK before the request leaves the process. A token still holding one, minted before it
joined that list (as InstallApps did), is refused until someone edits it (saving it drops the
permission) or revokes it: every Admin API call answers TOKEN_PERMISSION_NOT_ALLOWED, while a
call the CMS or the plugins service answers is refused as an invalid token (401). A token also never obtains a login that
outlives it: pos.devices.pair (and creating a POS device), creating or re-roling a staff member,
setting a staff password or login email, and creating or rotating a core API key all answer
PermissionDeniedError with TOKEN_CREDENTIAL_NOT_ALLOWED or TOKEN_API_KEY_NOT_ALLOWED. A token
may invite a new staff address with a role within its own permissions, when it carries no
location restriction (TOKEN_INVITE_NOT_ALLOWED otherwise), and send a staff member a password reset (requestAdministratorPasswordReset): both mail
the link to the recipient's own address. A token belongs to a store: on an organization's
workspace apiTokens.create (as the active store or as channelId) and apiTokens.update answer
BusinessRuleError with ORGANIZATION_WORKSPACE_NOT_A_STORE, for a SuperAdmin too, so mint it on
one of the organization's stores (a token for the whole organization is
organization.apiTokens, "An organization's API tokens" below). Mint and revoke credentials, and
pair tills, from a signed-in session:
const session: { token?: string } = {};
const human = createAdminClient({ urls, auth: { session } });
await human.auth.login('[email protected]', process.env.OPS_PASSWORD!);
const scoped = human.withTenant({ scope: { kind: 'channel', code: 'velvet-thread' } });
const created = await scoped.apiTokens.create({
name: 'ERP sync',
permissions: ['ReadOrder'],
expiresAt: '2027-01-01T00:00:00Z',
});
console.log(created.token); // shown exactly onceApps
An app is registered once by a platform operator from its sixtynine.app.json manifest, enabled
per tenant, and then installed by a tenant admin who consents to a subset of what it declares.
// Tenant side — needs `InstallApps` and a tenant context.
const available = await scoped.apps.list();
const installation = await scoped.apps.install({
appId: available[0]!.id,
grantedPermissions: ['ReadCatalog', 'ReadOrder'], // ⊆ declaredPermissions, and ⊆ your own
});Your own permissions are the ceiling: you cannot grant an app something you do not hold on that
channel. When the app's declarations later grow, the installation moves to needs_consent and its
pendingConsentReasons say what changed — tokens already minted keep the old grants and keep
working until apps.approveConsent(id) lands, so this is a notice, not an outage. apps.uninstall(id)
revokes every token immediately and keeps the row as provenance for what the app wrote.
Growth is granted only by NAMING it, and an approval should carry what the approver was shown:
await admin.apps.approveConsent(installation.id, {
grantedPermissions: [...installation.grantedPermissions, 'UpdateProduct'],
// What was on screen. The engine refuses the approval if the app changed
// while it was being read, so a re-registration between the screen and the
// button cannot be ratified by somebody who never saw it.
acknowledgedConsentReasons: installation.pendingConsentReasons ?? [],
});Omit grantedPermissions and the installation keeps exactly what it had — the right default for an
app that grew an event type or a metafield rather than a permission.
An app may also ask to own field groups — price, stock, product_details and
customer_details, each a fixed list of fields the platform defines (App.declaredFieldOwnership).
Grant them by key in grantedFieldOwnership on install or approveConsent; any other value
(an old product.price claim included) is refused with FIELD_GROUP_INVALID.
Field groups: one owner per group per store
A store hands each field group to one owner at a time — an app installation or a personal API
token (apiTokens.create({ fieldOwnership })). While it is owned, other writers are refused it
(below).
Every grant, on install, approveConsent, apiTokens.create and apiTokens.update, is judged
by the same rules, and only for the groups it ADDS (a group the owner holds already passes):
| Refusal | Class | When |
| --- | --- | --- |
| FIELD_GROUP_PERMISSION_MISSING | BusinessRuleError | the app or token holds no permission that changes the group (Price and Product details: UpdateCatalog or UpdateProduct; Stock: one of those, AdjustStock or UpdateStockLocation; Customer details: UpdateCustomer) |
| FIELD_GROUP_GRANT_NOT_ALLOWED | PermissionDeniedError | you cannot change that data yourself on the store |
| FIELD_GROUP_OWNER_CONFLICT | ConflictError | another app or token owns it here (a suspended app's claim still counts) |
| FIELD_GROUP_ERP_SYNCED | BusinessRuleError | Exact Online is connected (it writes all four groups), or the store belongs to an organization (Price and Product details) |
const owners = await admin.tenant.fieldGroupOwners();
// [{ group: 'price', ownerKind: 'app_installation', ownerId, label: 'Acme ERP', appSlug: 'acme-erp', paused: false }]
const synced = await admin.tenant.fieldGroupSyncSources();
// [{ group: 'price', source: 'exact', label: 'Exact Online' }, …]
// One click, recorded: the group comes off its owner, audited and announced as
// `app.field_ownership_changed`. InstallApps for an app's group, ManageApiToken for a token's.
await admin.apps.releaseFieldGroup({ kind: 'app_installation', id: owners[0].ownerId }, 'price', {
idempotencyKey: crypto.randomUUID(),
});An approval or a token update that does not name the groups keeps them, but drops a group the new
permissions can no longer change. An app or token reads what it owns from auth.whoami()
(fieldOwnership).
Field groups: what an owned group refuses
While an app or API token owns a group on a store, every OTHER writer through the Admin API is
refused a change of that group's data with FIELD_GROUP_LOCKED (BusinessRuleError, 422): staff
(a SuperAdmin included), other apps and tokens, bulk imports and pipelines. The owner's own calls
pass, and so does a bulk import it submitted. A suspended owner holds nothing until it resumes; an
installation waiting for consent still holds its groups.
try {
await admin.catalog.variants.update([{ id, price: 1999, priceBasis: 'gross' }]);
} catch (error) {
if (error instanceof BusinessRuleError && error.code === 'FIELD_GROUP_LOCKED') {
// error.details: [{ field: '/input/0/price', code: 'FIELD_GROUP_LOCKED',
// message: 'Prices on this store are managed by the app "Acme ERP". …' }]
}
}- An unchanged value passes. A field sent with the value it already holds is not a change: it is left out of the write, so the owner keeps its "managed by" provenance. Send whole records if you like; only the values you change are judged.
- What counts: updating a group's fields on an existing product, variant, customer or address; deleting a product, variant or customer whose groups are owned; adding, changing or removing a customer's address; linking or unlinking a product's option groups; a stock adjustment, a stock transfer's stock moves, and deleting a location that holds stock.
- What never counts: creating a record; a stock move that follows an order (a sale, a reservation, a fulfilment, a return put back in stock); a shopper editing their own details on the storefront; price lists, visibility, metadata and documents, which are in no group.
- Shared rows. A record several stores hold is one row: its product details, its customer details, and a variant's tax class, cost price and stock settings (tracking, thresholds, backorders). An owner in another store holding the record counts for those too; the refusal names it only as "another store's app" (or API token), and only that owner can change them. The same app installed in both stores is one owner. A variant's prices and stock levels are each store's own, so only this store's owner counts for them.
- Customer data your app cannot read. When your app's customer-data level hides a field, a value you send for it is judged a change even when it equals the stored one: the lock never tells you whether you guessed a value you may not read.
- Property values are product details. A changed property value, a forced facet or facet value delete, and a property create or change (its options or filtering), a property or property group delete or a field-facet configuration that takes values off products are refused the same way; a value sent back unchanged passes. So are a forced option group delete and an asset delete (everywhere, or of an asset only this store holds) that take options or images off products, in any store.
- Not refused yet (a later release): filter values a collection's rules assign, a store's price written when products are assigned to it, the platform's translation repair, an order import filling a customer's empty fields, and an order's address saved into an empty address book.
// App side — the app authenticates as itself. No token handling.
const app = createAdminClient({
urls,
auth: {
app: { appId: process.env.APP_ID!, kid: '2026-09', privateKey: process.env.APP_PRIVATE_KEY! },
installationId: process.env.INSTALLATION_ID!,
},
tenant: { scope: { kind: 'channel', code: 'velvet-thread' } },
});
// A token is minted on the first call and refreshed for you from then on.
const me = await app.auth.whoami(); // kind: 'app_installation'
me.installation?.status; // 'needs_consent' → tell the merchant
me.permissions; // what this installation was actually granted
const products = await app.content.forms.list(); // …and any module those permissions allowThe SDK signs a short-lived Ed25519 assertion with your private key, exchanges it for an s69i_
bearer, and refreshes that bearer a minute before it expires. The key never leaves your process and
the bearer is never yours to store. privateKey takes a PKCS#8 PEM, a JWK, or an already-imported
CryptoKey for a caller whose key lives in a KMS handle.
The assertion carries aud — the origin of the deployment it may be exchanged at — and the platform
verifies it strictly. The SDK derives it from the urls.vendureUrl you already configured, so there
is nothing to set. It matters because iss and sub name one app on one installation, and those ids
survive a database restore unchanged: without aud, an assertion minted for a staging restore of a
production dump verifies against production. A mismatch answers
400 ASSERTION_AUDIENCE_MISMATCH naming the origin that deployment expects.
Signing by hand? Read the audience from the authenticated capability manifest and put it in
aud verbatim — do not re-derive it. Through this SDK that is
(await admin.tenant.capabilities()).assertionAudience; straight off the wire
(GET /capabilities) the member is assertion_audience, since the SDK camelises response keys. A
null there means the plugins service cannot state one, which does not mean the exchange accepts an
assertion without aud — the two run as separate deployment resources, so ask the operator rather
than omitting the claim.
An assertion with no aud at all is still accepted until 2026-12-01; those exchanges answer 200
with Deprecation, Sunset and Link headers, and the platform logs APP_ASSERTION_AUD_MISSING
naming your app. After the sunset they are refused.
apps.* itself is a TENANT ADMIN's surface, not an app's: InstallApps is never grantable to an
app or to an API token, because an installation is a minted credential and an app or token that
could install one could grant another app a subset of its own permissions with nobody consenting,
outliving the token's expiry and revocation. An app reads its own state
through auth.whoami().
Registering apps, managing their keys, suspension and per-tenant enablement live under
platform.apps.* and need a human SuperAdmin session — an app that could register apps could
declare anything and enable itself.
APP_MANIFEST_SCHEMA (and its APP_MANIFEST_SCHEMA_SOURCE text) is re-exported from THIS package,
so a build step can validate a manifest before anyone tries to register it. It is defined in
@sixtyninecommerce/core, which is a transitive dependency pnpm will not resolve for you — import it
from here.
The registry is deliberately STRICTER than that schema: it refuses a padded string, a duplicate list entry, an unknown key inside a metafield definition, and a namespace naming another app. Validating against the schema says "worth sending", never "certain to be accepted".
An app's settings, and its own state
Two surfaces, and the difference between them is who owns the data.
apps.installation.config is the settings form the app declares and the merchant fills in. A
tenant admin with InstallApps reads and writes it; the owning app may READ its own and may never
write it — an app that could write its own settings could grant itself whatever its own code reads
out of them.
const { value } = await admin.apps.installation.config.get(installationId);
await admin.apps.installation.config.set(installationId, { endpoint: 'https://acme.test/hook' });What you save is validated against the app's declared configSchema. A refusal is CONFIG_INVALID
with one details[] entry per failing JSON Pointer, so a form can mark every field at once. An app
that declares no schema has nothing to configure (APP_DECLARES_NO_CONFIG), and a schema using a
keyword or type the platform does not recognise refuses every save rather than half-checking it
(CONFIG_SCHEMA_UNENFORCEABLE — the app's author must re-register).
That covers both axes: a keyword the platform does not recognise, and one it recognises but cannot
apply to the level's declared type — minLength on an integer, required on a string — which
would constrain nothing. Declare each keyword on a type it applies to: minLength/maxLength/
pattern on a string, minimum/maximum on a number or integer, minItems/maxItems on an
array, required on an object. Reading never fails because the
schema moved: the stored value comes back as it was saved even where the current schema no longer
describes it, so you can show it and offer to clear it.
customObjects is the app's own private state, (container, key) documents that only an
installation token reaches. A human session — SuperAdmin included — is refused
(CUSTOM_OBJECT_FORBIDDEN), and one installation never sees another's rows.
await admin.customObjects.set('sync-state', 'cursor', { at: lastSyncedAt });
const page = await admin.customObjects.list('sync-state');
await admin.customObjects.delete('sync-state', 'cursor');Quotas are per installation per store: 128 KiB a document and 10,000 live objects. The row quota is
checked only when a NEW key is written, so a full installation can still shrink a value it already
has, and the merchant's settings row never counts against it. A container name is 1-64 characters,
starts with a lowercase letter or digit and uses only lowercase letters, digits, - and _; a key
is 1-200 characters with no control characters.
Both value payloads keep their keys exactly as you send them — the case conversion this SDK
applies to its own field names never reaches inside them, so a field named order_ref stays
order_ref.
Neither write is deduplicated by an Idempotency-Key. customObjects.set replaces a value by key,
so repeating it is the same as doing it once; the settings write is a human saving a form, where
replaying an earlier answer to a later edit would be wrong.
Knowing what credential you hold
const who = await admin.auth.whoami();
who.kind; // 'api_token' | 'user' | 'app_installation'
who.token?.expiresAt; // null means it never expires
who.permissions; // effective on the ACTIVE channel, not the union
who.entitlements; // connected plugin keys for that channel
// For an app: who.app (id, slug, name) and who.installation (status, consentVersion).
// `installation.status === 'needs_consent'` is how an app learns the merchant has
// to approve declarations it has since grown.For a machine credential this is the only way to learn any of it without being refused. It needs a
tenant — permissions and entitlements have no meaning without a channel — and it is admin-plane
only: a storefront principal is refused with PermissionDeniedError.
Which SDK version you run (sdkHeader)
From a server (Node, Bun, Deno, an edge runtime) every call says which SDK made it:
x-sixtynine-sdk: admin@<this version>, so the platform's operators can see which SDK version an
integration runs. It grants and refuses nothing, is independent of agent, and is never sent from a
browser or React Native. sdkHeader: '[email protected] [email protected]' names your integration
beside the SDK (one to four <name>@<version> products, at most 128 characters; anything else is
SDK_HEADER_INVALID when the client is created), and sdkHeader: false sends nothing. More:
Versioning.
Locations
A location profile is a Vendure stock location plus the fields a head, a POS or an order-entry
client needs to address it. TenantContext.location is a profile's stockLocationId — not its
code — and setting it puts x-location on every request.
const locations = await admin.locations.list(); // a Page; the list is complete today
await admin.locations.upsert({
stockLocationId: locations.items[0]!.stockLocationId,
code: 'main-warehouse', // kebab-case, unique per store
name: 'Main Warehouse',
timezone: 'Europe/Amsterdam', // IANA zone name
openingHours: { mon: [{ open: '09:00', close: '17:30' }] },
address: { streetLine1: 'Keizersgracht 1', city: 'Amsterdam', postalCode: '1015 CJ', countryCode: 'NL' },
});
const register = admin.withTenant({ location: 'the-stock-location-id' });upsert states the whole profile: an omitted optional field clears it. address is the one
exception — it writes through to the stock location's own field, so omit it to leave that field
alone and pass null to clear it. get() answers null when a stock location has no profile, and
delete() answers false when there was none; neither is an error. Refusals arrive as
LOCATION_CODE_TAKEN (ConflictError), INVALID_TIMEZONE / INVALID_OPENING_HOURS /
INVALID_ADDRESS (ValidationError) and LOCATION_NOT_FOUND (NotFoundError). A token or app
installation restricted with locationIds changes or clears the address (the location's parcel
sender) only at a location it holds, LOCATION_NOT_ALLOWED (PermissionDeniedError) otherwise;
sending the stored address back unchanged passes.
An API token can be pinned to a set of them, after which it may only send an x-location naming
one of those (LOCATION_NOT_ALLOWED otherwise, enforced on the Admin API today):
await scoped.apiTokens.create({
name: 'POS register 1',
permissions: ['ReadOrder', 'ReadStockLocation'], // no POS template exists yet
locationIds: ['sl-1'],
});
await scoped.apiTokens.update('tok-1', { locationIds: null }); // null lifts it; omitting leaves itAn empty list is refused with INVALID_LOCATION_RESTRICTION — "allowed nowhere" is never what a
caller means, so pass null to lift the restriction instead.
auth.whoami().locations is meant to report the same list for the credential in hand, but the
plugins GET /me projection still answers null for every principal — read apiTokens.list() for
the restriction a token actually carries until that is wired.
Store pickup
A shop can offer click & collect: the shopper picks it at checkout, the shop marks the order ready (the customer is emailed once), and the shop's till hands it over after checking the order code. Turning it on at a location needs the location's address and an active POS register there.
const settings = await admin.locations.pickup('sl-2'); // { enabled, holdDays, instructions, hasAddress, hasTill }
await admin.locations.setPickup({
stockLocationId: 'sl-2',
enabled: true,
holdDays: 7, // 1 to 60 (STORE_PICKUP_HOLD_DAYS)
instructions: 'Collect at the service desk.', // at most 500 characters
});
const page = await admin.pickups.list({ stockLocationId: 'sl-2', statuses: ['preparing'], take: 25 });
const pickup = await admin.pickups.forOrder(orderId); // null when the order is not collected at a shop
await admin.pickups.markReady(pickup!.id); // the hold starts; ready again changes nothingsetPickup states the whole settings (an omitted instructions clears them); the store's first
enabled location creates its store-pickup shipping method, which you may rename or disable like
any method. Turning a location on without an address is STORE_PICKUP_ADDRESS_REQUIRED, without a
register STORE_PICKUP_TILL_REQUIRED (both BusinessRuleError); bad hold days or instructions are
STORE_PICKUP_SETTINGS_INVALID (ValidationError). A pickup is preparing, ready, collected,
expired (its hold ran out and the stock went back, unless releaseBlocked says why nothing moved)
or cancelled; more statuses may come, so treat an unknown one as itself. "Ready" on a pickup that
is past it is STORE_PICKUP_STATE (BusinessRuleError). There is no hand-over method: that is the
till's act.
Stock
Stock per location by state, the ledger with the reason each movement was made, and adjustments
that cannot be made without one. The tenant's location is the default place for all three.
const counter = admin.withTenant({ location: 'the-stock-location-id' });
const [here] = await counter.stock.position('variant-id');
// { onHand, allocated, available, incoming, onOrder, reserved, trackInventory } — integer units
await counter.stock.adjust(
{ variantId: 'variant-id', delta: -2, reason: 'damaged in transit', referenceDocumentRef: 'DMG-114' },
{ idempotencyKey: idempotencyKey() },
);
for await (const movement of await admin.stock.movements('variant-id', { type: 'ADJUSTMENT' })) {
console.log(movement.quantity, movement.reason, movement.referenceDocumentRef);
}available is onHand − allocated − threshold (the location's out-of-stock threshold, else the
variant's), exactly what the allocator can still reserve there, and it is not floored: an order
allowed to backorder shows as a negative figure. reserved is what a positive threshold holds back.
onOrder is what suppliers still have to deliver there (see Purchase orders). stock.position
and stock.adjust both answer a position with onOrder, so both need an engine with purchase-order
supplier confirmation (step 2.4); against an older one they fail GraphQL validation
(GRAPHQL_VALIDATION_FAILED).
reason is required on every adjustment and is yours to word — the platform never interprets it.
A location another store owns is refused with LOCATION_NOT_IN_CHANNEL, and a token or app pinned to some locations (locationIds) is refused LOCATION_NOT_ALLOWED elsewhere and reads only its own when it names none; at a location you share
with another store, both see the same levels and adjustments, but each only its own orders'
movements and its own reasons, so a store's rows may not sum to the level. At a location shared from an organization's workspace,
only the stores of the legal entity that owns it adjust stock: another store and the workspace are
refused STOCK_LOCATION_OWNER_WRITES_ONLY (BusinessRuleError), while orders and returns still
move stock there. catalog.variants.setStockLevels follows the same rule. A level restating what
is stored passes and writes nothing, but one that moved since you read it is a change, so name only
the levels you are changing. A
decrease may not take on-hand below zero (STOCK_WOULD_GO_NEGATIVE). The adjustment reaches event
subscribers as stock.level_changed (cause: 'adjustment', no reason) and as stock.adjusted,
which carries the reason and reference and is flagged as personal data.
stock.alerts is the store's low-stock alert, judged by the engine: lowStock() answers how many
variants are at or below their out-of-stock threshold (enabled, inventory tracked, stock on hand at
the store's warehouses as ProductVariant.stockOnHand reads it; the variant's own threshold, else
the store's), with no variant in the answer, so a badge can refresh it cheaply;
lowStockVariants({ limit }) pages the variants behind it, lowest first, each with the threshold it
was judged against (limit 1–50). It is not reports.lowStock, which judges each location's
available against a threshold you name. sampled is true only for a store holding more tracked variants
than one alert judges, and then the most recently updated ones were judged. An older engine refuses
both with GRAPHQL_VALIDATION_FAILED.
const { count, threshold } = await admin.stock.alerts.lowStock();
const lowest = await admin.stock.alerts.lowStockVariants({ limit: 10 });Each of those variants, the inventory report's low stock and a reorder suggestion carry
waitingCount: the back-in-stock alerts waiting on the variant (for a suggestion, in the stores
whose demand counts at its location). It is shown, never part of the judgement or the maths, and
is null on an engine without back-in-stock alerts (the SDK asks an older engine once more
without the field).
stock.notifications is the store's back-in-stock workspace. settings.get()/set(patch) are the
store's rules; demand({ search, sort }) ranks the variants shoppers wait for (the most alerts
first, with the units the store sells now, saleable: 0 none, null unlimited);
waitingCounts(ids) reads up to 250 variants at once (a list's badge); subscribers({
productVariantId, status }) pages the alerts oldest first, each with its status
(AWAITING_CONFIRMATION, PENDING, QUEUED, SENT, PARKED, SKIPPED, REMOVED) and the
shopper's address, which is customer data (redacted below customerData: full); remove(id)
ends an alert for good; recoveredRevenue({ from, to }) sums, per currency, the orders sent alerts
brought in within seven days (web, till and trade portal only); pendingWhileDisabled() counts the
alerts a switched-off back-in-stock email holds back. customers.stockNotifications(customerId)
lists one customer's alerts. Demand and counts need ReadCatalog or ReadProduct, the alerts
ReadCustomer (remove: UpdateCustomer), revenue ReadOrder.
const demand = await admin.stock.notifications.demand({ sort: { field: 'WAITING' } });
const waiting = await admin.stock.notifications.subscribers({
productVariantId: demand.items[0]?.productVariantId,
status: ['PENDING', 'QUEUED'],
});stock.transfers.* wraps the stock-transfer module (create → ship → receive → close, or cancel;
postConsumption for a one-location consumption) — its note and cancelReason stay on the
transfer. A location shared from an organization's workspace takes no transfer, in either direction:
create, update, createReturn and ship refuse one at either end
STOCK_LOCATION_HUB_TRANSFER_REFUSED (BusinessRuleError), for every caller, and
stock.locations.delete refuses it as transferToLocationId, while a transfer already in transit is
received, closed or cancelled as usual. A consumption there follows the rule of stock.adjust. A
credential restricted with locationIds is refused LOCATION_NOT_ALLOWED
(PermissionDeniedError) unless it holds the end the call acts for: the source (whose stock is
debited) to create, createReturn, update, ship, postConsumption or cancel, the
destination to receive, either end to close (the source when the close sends a line back to
it); it lists, and can get, only transfers with an end it holds. Each step reaches event
subscribers as stock_transfer.created, .shipped, .shipment_removed, .received
(completed_by: receive, close, or post for a consumption) and .cancelled, which does not
carry the cancelReason: read it with stock.transfers.cancellation(id).
const page = await admin.stock.transfers.list({ search: 'autumn', searchMode: 'all' }); // code or label
const transfer = await admin.stock.transfers.get(page.items[0].id); // null when the store has none
transfer?.items[0].unitCost; // a posted consumption's cost snapshot
transfer?.destinationLocation?.address; // the location's own address, as stored
transfer?.fulfillmentHandlerCode; // how it shipped: manual, myparcel, hendriks-3plA list row carries the transfer, its lines (variant id, SKU and name) and who created it; get
and every write answer the detail as well: who shipped and received it, a consumption's value
(consumptionTotalValue, minor units), the transfer note (pdfStorageKey), the handler and its
arguments, the first MyParcel parcel and the Hendriks export path. search matches the code
unless searchMode is all (code or label). estimatedArrivalAt is a calendar day kept as that
day's 00:00 UTC: read it as the UTC day. None of these writes takes an idempotency key, and the
client never repeats one: shipping a transfer already in transit answers it unchanged, receive
takes each line's running totals, and a second post or cancel is refused. Ship, post, receive, an
in-transit cancel and a close that sends units back are refused FIELD_GROUP_LOCKED while an app
or API key owns the store's Stock.
stock.allocations.release(orderId) hands back everything a stuck, unfulfilled order has reserved
without changing its state.
What one location holds, and what moved there, a page at a time:
const here = await admin.stock.levelsAt(locationId, { search: 'rioja', limit: 25 }); // most on hand first
here.items[0]; // { variantId, sku, variantName, product: { id, name, featuredAsset }, onHand, allocated, updatedAt }
for await (const movement of await admin.stock.movementsAt(locationId)) {
console.log(movement.type, movement.quantity, movement.sku, movement.order?.code); // newest first
}sort: 'ASC' lists the fewest on hand first; the engine answers at most 200 levels a page. Both
need ReadStockLocation, and the location must be the store's own: another store's is refused
LOCATION_NOT_IN_CHANNEL, never read. A location several stores share lists only this store's
variants, its own order movements and every adjustment: another store's order rows stay out, so the
rows may not sum to the level, and levelsAt answers the true level. A caller without ReadOrder
(a warehouse role) gets the same rows of movementsAt, each with order: null.
Stock settings
stock.settings is the store's stock settings as the engine reads them: whether the storefront
sees operational stock numbers, which warehouses back the storefront, and the backorder default,
beside the store's own tracking and threshold and the platform defaults.
const settings = await admin.stock.settings.get();
// { channelId, trackInventory: true, outOfStockThreshold: 0,
// defaultTrackInventory: true, defaultOutOfStockThreshold: 0, exposeStockToStorefront: false,
// storefrontStockLocations: { mode: 'ALL', locationIds: [], reason: null }, backorderPolicy: 'deny' }
// How a change WOULD read. Writes nothing.
await admin.stock.settings.preview({ storefrontStockLocationIds: [warehouseId] });
// Only the named fields are written; [] clears the warehouse selection (every warehouse).
await admin.stock.settings.set({ storefrontStockLocationIds: [warehouseId], backorderPolicy: 'incoming' });
// Before turning tracking on or off: what open orders hold, and what of it predates the record.
await admin.stock.settings.previewTrackingChange({ trackInventory: false });
// { tracksBefore: true, tracksAfter: false, heldOrders: 23, heldUnits: 2252,
// unrecordedOrders: 0, unrecordedUnits: 0 }storefrontStockLocations.mode is ALL (every warehouse), SUBSET (only locationIds) or
MALFORMED: the stored value cannot be read, the engine sells from every warehouse and logs it,
and reason says why; saving a selection replaces it. The engine's stock maths read a variant's
own tracking and threshold, else the store's trackInventory / outOfStockThreshold, else the
platform defaults (defaultTrackInventory, defaultOutOfStockThreshold) for a store that leaves
one null (inherit).
Each reservation keeps the tracking setting it was made under: its later shipment, cancellation or
release moves the stock levels as the reservation did, whatever the store, platform or variant
setting is by then, and only new reservations follow a change (heldOrders / heldUnits count
those that keep the old one). Except a reservation made before the engine recorded the setting
(unrecordedOrders / unrecordedUnits): it follows the setting in force when it moves, so a change
runs it under the other setting: turned off, a reservation made while tracked stays on the stock
levels (no door gives it back); turned on, one made while untracked comes off other orders'
reservations, or the shipment fails. Ask previewTrackingChange before a change, as Settings →
Stock does; it answers every count 0 when tracking would not change.
Reading needs any administrator permission on the store; set and previewTrackingChange need
UpdateChannel. Refusals,
from preview and from set before anything is written: CHANNEL_SETTINGS_INVALID
(ValidationError, details[] per field) for a null tracking, threshold, flag or policy, a
policy that is not deny, incoming or unlimited, a blank warehouse id or more than 500.
Stock locations
stock.locations is the store's stock locations themselves; a location's profile (code, time zone,
opening hours, address) stays locations.
for await (const location of await admin.stock.locations.list({ term: 'back', sort: { field: 'name', direction: 'ASC' } })) {
console.log(location.name, location.isPublic, location.outOfStockThreshold);
}
const created = await admin.stock.locations.create({ name: 'Back room', isPublic: false });
await admin.stock.locations.update(created.id, { outOfStockThreshold: 2 }); // a patch
await admin.stock.locations.delete(created.id, { transferToLocationId: 'sl-1' });term matches the name or the description, ignoring case, through the engine's own list filter;
sort takes one key (name, createdAt, updatedAt). get answers null for an id the store
does not hold. The parcel sender (address, default sender) is not part of these inputs: it has its
own door, and the engine refuses a change to it here (STOCK_LOCATION_SENDER_READ_ONLY). delete
moves the location's stock levels to transferToLocationId, or deletes them with it; the engine
keeping the location arrives as STOCK_LOCATION_NOT_DELETED (BusinessRuleError) in its own words.
Another store's location is ENTITY_NOT_FOUND; a credential restricted with locationIds must hold
the deleted location and the target (LOCATION_NOT_ALLOWED); and while an app or API token owns the
store's Stock, deleting a location that holds stock is FIELD_GROUP_LOCKED.
users.warehouses is which warehouses a staff member works in (Settings → Team → a user →
Warehouses), with the store's warehouse entitlement (WAREHOUSE_NOT_ENABLED without it):
const assigned = await admin.users.warehouses.get(administratorId); // [{ id, name }]
await admin.users.warehouses.set(administratorId, ['sl-1', 'sl-2']); // exactly these; [] for noneA credential restricted with locationIds sees and assigns only its own locations, keeps the
person's other assignments as they are, and is refused LOCATION_OUT_OF_SCOPE for any other id.
An API token or app installation signs in as an administrator of its own, assigned here like a
person. A caller holding UpdateOrder never needs an assignment. Without it, the doors that act on
a warehouse's goods or fulfilments hold the caller to the warehouses assigned to its administrator,
within its locationIds:
- Every caller without
UpdateOrder, withlocationIdsor not: the warehouse workspace (The warehouse workspace):fulfillments.warehousemine,counts,demand,list,get,pickList,appendCandidates,claim,unclaim,reopen,pack,book,unbook,setHandler,correct,ship,deliver,appendanddetach; the shared create flow (fulfillments.create),fulfillments.parcelTracking,fulfillments.chargedParcelCount, the warehouse documents (GraphQLprintWarehouseDocuments), theCORRECT_FULFILLMENTanswer oforders.statusfor a warehouse-role caller, and, at a location a store with thewarehouseentitlement holds, goods receipts (purchasing.receipts.post,reverse; Receipts) and supplier returns (purchasing.returns.create,updatewhen it moves the return,ship). At a location no such store holds, those receipt and return doors read no assignment. - A caller restricted with
locationIds: the MyParcel reads aReadOrderholder may make (fulfillments.myparcelLabel,shipping.myparcel.shipments.get,shipping.myparcel.labels.status,shipping.myparcel.labels.download, GraphQLmyparcelShipmentTrackingDetail). WithoutlocationIdsthey read no assignment.
Every other door holds a credential to its locationIds alone and reads no assignment: stock
adjustments (stock.adjust), stock transfers (stock.transfers.*), locations.upsert,
orders.cancelWithMoney, and every fulfilment, carrier and packing-slip door that needs
UpdateOrder, fulfillments.warehouse.setWarehouse included. Three warehouse reads take no
location scope at all: fulfillments.warehouse.detachPreview (ReadOrder),
fulfillments.warehouse.handlers (the configured carriers' definitions) and GraphQL
warehouseDocumentJob (the caller's own document job).
Closing a transfer with a shortfall
close ends an in-transit transfer as received. A line still short is settled per line: LOST
writes its unaccounted units off, RETURN_TO_SOURCE credits them back to the source location
(they never left, or came back). A line shortfall does not name is written off as lost only with
acceptShortfallAsLost; otherwise the close is refused, as it is for a line named twice, a line of
another transfer or a line with nothing outstanding (ValidationError, nothing changed).
await admin.stock.transfers.close(transferId, {
acceptShortfallAsLost: true, // every line not named below is written off as lost
shortfall: [{ lineId, disposition: 'RETURN_TO_SOURCE' }],
});
const settled = await admin.stock.transfers.shortfall(transferId); // null if absent
// { transferId, state, totalLostQuantity, totalReturnedToSourceQuantity,
// lines: [{ lineId, variantId, sku, quantity, receivedQuantity, lostQuantity,
// returnedToSourceQuantity, outstandingQuantity }, …] }shortfall(id) is its own request and get never asks for the returned quantity, so a caller
ahead of the engine sees only shortfall(id) and a close with shortfall refused.
Stock transfer returns
Stock sent somewhere temporary (an event, a pop-up) comes back as a return: an ordinary transfer with its own code, linked to the transfer it reverses. Its source is pinned to that transfer's destination, where the goods are; its destination is any other location of the store. Each variant is capped at what the transfer received minus its returns that are not cancelled, so nothing received means nothing returnable, and what is not returned stays where it is.
const links = await admin.stock.transfers.returns(transferId); // ReadStockLocation; null if absent
// { returnOfTransferId, returnOf, returns: [{ id, code, state, label, sourceLocation,
// destinationLocation, createdAt, totalQuantity }, …],
// returnableLines: [{ variantId, sku, variantName, receivedQuantity, returnedQuantity,
// returnableQuantity }, …] }
const back = await admin.stock.transfers.createReturn(transferId, {
destinationLocationId: warehouseId, // any location of the store but the return's source
label: 'Event ABC',
lines: [{ variantId, quantity: 12 }], // ≤ returnableQuantity per variant
});
await admin.stock.transfers.ship(back.id); // then receive / close / cancel like any transfer
const eventReturns = await admin.stock.transfers.list({ returnOfTransferId: transferId });
const plainTransfers = await admin.stock.transfers.list({ isReturn: false });Only a transfer (not a consumption, not itself a return) in transit or received can be
returned (STOCK_TRANSFER_NOT_RETURNABLE); a quantity above the cap, or a variant not on the
transfer, is RETURN_QUANTITY_EXCEEDS_RECEIVED (also on an update of a return draft's lines),
and an update moving a return's source is RETURN_SOURCE_LOCKED — each a
BusinessRuleError. createReturn needs AdjustStock (or the older UpdateStockLocation), like
every transfer write, and a credential restricted with locationIds must hold the return's
source, which is the original transfer's destination. A cancelled return frees its quantity
again. returns(id) is its own request and get / list select none of its fields, so a caller
ahead of the engine sees only returns, createReturn and a list naming the return filters
refused.
Stock transfer parcels
A transfer shipped with MyParcel as N parcels (ship(id, { fulfillmentHandlerCode: 'myparcel',
handlerArgs: { parcelCount: 2, … } })) is booked as N MyParcel shipments, one label and one
barcode each. The booking happens inside ship, one MyParcel call per parcel, so ship (like
cancel, which voids every parcel, and retryMyparcelShipments) is allowed up to 120 s; a
REQUEST_TIMEOUT or an abort there does not mean the call failed, so read the transfer again
before repeating it. The labels and barcodes follow in jobs after ship returns.
const shipment = await admin.stock.transfers.parcels(transferId); // ReadStockLocation
// { parcelCount: 2, parcels: [{ ordinal: 1, shipmentId, barcode, labelReady, lastError }, …],
// trackingCodes, lastError, state, fulfillmentHandlerCode }
if (shipment?.lastError && shipment.parcels.length < (shipment.parcelCount ?? 1)) {
await admin.stock.transfers.retryMyparcelShipments(transferId); // books only what is missing
} else if (shipment?.parcels.some((parcel) => !parcel.barcode)) {
await admin.stock.transfers.labelMyparcelParcels(transferId); // labels only, one job
}
const label = await admin.stock.transfers.myparcelLabel(transferId, 'A6'); // one merged PDFparcels lists the parcels BOOKED so far, so it is shorter than parcelCount while the booking
runs or after it stopped part-way; lastError then says why, and retryMyparcelShipments books
exactly the missing ones (a parcel MyParcel already holds is adopted, never booked twice). On a
draft whose shipment was removed, lastError names any parcel MyParcel refused to void. Several
parcels to a destination outside the EU customs union are refused at ship
(PARCEL_COUNT_NEEDS_CUSTOMS) before any stock moves. The merged label is printable only while the
transfer is in transit (MYPARCEL_LABEL_NOT_AVAILABLE otherwise).
labelMyparcelParcels(transferId) queues one job that labels every booked parcel still missing its
label or barcode (never asking twice for one that has it) and answers the parcels as they stand;
read parcels() again for the labels. Once every parcel is booked it does what
retryMyparcelShipments does, without the booking step; with a parcel not booked it is refused
(MYPARCEL_LABEL_NOT_AVAILABLE), as it is outside transit.
unretractedShipments lists the MyParcel shipments a removed shipment or a cancel could not
retract: MyParcel deletes only concepts, and keeps a registered label. The same record as a
fulfillment's (unretractedShipmentsOfOrder, below); it survives a re-ship.
Stock transfer cancellation
cancel(id, { reason }) cancels a draft, or an in-transit transfer whose units are back on the
source (less any recorded lost). It voids every MyParcel parcel itself, but nothing reaches
Hendriks: a transfer exported to Hendriks is cancelled here while its BluJay order still stands.
const before = await admin.stock.transfers.cancellation(transferId); // ReadStockLocation
if (before?.hendriksExport) {
// Warn first: after the cancel, Hendriks must be asked to retract this order, or it ships.
console.warn(`Ask Hendriks to retract ${before.hendriksExport.bluJayOrderNumber}`);
}
await admin.stock.transfers.cancel(transferId, { reason: 'Event called off' });
const after = await admin.stock.transfers.cancellation(transferId);
// { state: 'cancelled', cancelledAt, cancelReason, cancelledBy: { id, firstName, lastName },
// hendriksExport: { bluJayOrderNumber } | null }cancelledBy is null for a transfer cancelled before the engine recorded who did. cancellation
is its own request and get / list / cancel select none of its fields, so a caller ahead of
the engine sees only cancellation refused.
Stock transfer shipment, note and numbering
// A wrong handler or carrier: back to an editable draft, the units back on the source.
const draft = await admin.stock.transfers.removeShipment(transferId);
// A Hendriks export that did not land at ship time (hendriksRemotePath still null).
const exported = await admin.stock.transfers.retryHendriksExport(transferId);
// The transfer note (a consumption's: its consumption note) again.
const noted = await admin.stock.transfers.regeneratePdf(transferId); // noted.pdfStorageKey
// Open the note: a five-minute signed link, minted per call.
const { url, expiresAt, filename } = await admin.stock.transfers.document(transferId);
const format = await admin.stock.transfers.numbering.get(); // { prefix, suffix, digits, yearFormat, yearReset }
await admin.stock.transfers.numbering.set({ prefix: 'TR-', digits: 5 }); // a patchremoveShipment undoes ship: it voids every MyParcel parcel inside the call (allowed up to
120 s; a REQUEST_TIMEOUT does not mean it failed) and clears the handler, carrier, tracking code,
ETA, parcels and note, keeping the delivery address for the re-ship. It does NOT withdraw a Hendriks
export or any other 3PL's shipment: retract that at the 3PL. It is refused for a transfer not in
transit, so a repeat restores nothing twice, and once any unit was received or lost (close or
cancel instead). retryHendriksExport uploads the export once: a transfer already exported, not
shipped with Hendriks, or not in transit is refused, and so is a store without Hendriks set up
(that one with no code yet: it arrives as a GRAPHQL_ERROR, and nothing is uploaded).
regeneratePdf is refused for a draft. A note can print a customer's site, contact name and
phone, so it lives in private storage: pdfStorageKey is a storage key, never a URL, and the note
opens only through document(id), a signed link that works for five minutes (open it at once, ask
again for a fresh one, never store or share it). document is refused STOCK_TRANSFER_NOT_FOUND
for a transfer the store does not have or the credential holds neither end of,
STOCK_TRANSFER_DOCUMENT_NOT_FOUND when there is no stored note (a draft, or one never generated:
regeneratePdf stores it) or the file is gone, and PRIVATE_STORAGE_UNCONFIGURED on a deployment
without private document storage. The numbering get answers the format the store's next
code takes (its organization's while it inherits settings.numbering_formats; the counter is
always the store's own); set writes the store's own row and answers it, so read get again
for the format in use. An unknown yearFormat is refused NUMBERING_YEAR_FORMAT_INVALID.
Parcel count suggestion
const suggestion = await admin.shipping.parcelCountSuggestion({
items: [
{ productVariantId: bottleId, quantity: 6 }, // from one order
{ productVariantId: whiteId, quantity: 6 }, // from another
],
destinationCountryCode: 'CH',
});
// { count: 1, resolvable: true, exact: true, destinationRequiresCustoms: true, ... }How many parcels the active channel's parcel-count rule gives a set of variant + quantity items,
for prefilling a fulfilment: a consolidated group sends the selected items of all its orders. Items
of one variant are summed. resolvable: false (reason: 'NO_PARCEL_RULE') means the store has no
rule and count is 1; exact: false means some variants lack the data the rule needs, so count
is a lower bound. reason and warnings are codes (UNIT_OVER_MAX_WEIGHT, CAPPED), and new ones
may appear. The destination is optional and only answers destinationRequiresCustoms.
1 to 500 items with a quantity of 1 to 100,000 each, and a two-letter country code; anything else is
refused before any request with PARCEL_COUNT_INPUT_INVALID (ValidationError). A variant that is
unknown, or another store's, is PARCEL_COUNT_VARIANT_NOT_FOUND (NotFoundError). Needs
ReadCatalog or ReadFulfillment.
Retention and legal holds
await admin.tenant.retention.get(); // class, holdUntil, holds
await admin.tenant.retention.set({ retentionClass: 'gdpr-7y' }); // omitting holdUntil CLEARS it
await admin.tenant.retention.hold('litigation 2026-114'); // this principal's hold
await admin.tenant.retention.release('litigation 2026-114');Always the active channel — there is no channel argument, which is what lets a tenant-scoped
token hold its own store and reach no other. retentionClass is free text (default standard) that
the platform stores and does not interpret yet. While any hold stands, or holdUntil is in the
future, a hard delete of the store is refused with RETENTION_HOLD_ACTIVE (BusinessRuleError).
A named hold also refuses every customer erasure on the store (customers.erase); the holdUntil
window alone does not, because an erasure keeps the records the window protects.
release() lifts only the calling principal's own hold: another's is RETENTION_HOLD_NOT_YOURS
(PermissionDeniedError) unless you are a SuperAdmin, which releases every hold. A bad class or
instant is RETENTION_INPUT_INVALID (ValidationError).
A sandbox store (platform.provisionStore({ sandbox: true })) bypasses holds: a hold can be
placed on it, but never stops its hard delete. The bypass leaves an audit row
(retention.hold.sandbox_bypass) naming what it stepped over.
Transactional emails per event
const configs = await admin.tenant.notifications.list(); // one entry per event the store may use
await admin.tenant.notifications.update('order_placed', { sender: 'none' }); // your system sends it
await admin.tenant.notifications.update('order_placed', {
enabled: true,
recipients: { bcc: [{ type: 'text', value: '[email protected]' }] }, // replaces that list only
});sender: 'none' stops the platform sending that email; your own system sends one in its place from
the matching platform event, and every dispatch the platform skips is still written to the store's
email delivery log (status skipped, metadata.skipReason: 'SENDER_NONE'). Each accepted update
emits one notification_config.changed with the resulting enabled and sender. Identity emails
(the account category: password reset, magic link, invitation, email change, email and chat
verification), gift_card_issued and stock_notification_confirm refuse none with
EVENT_ALWAYS_PLATFORM_SENT (ValidationError): they carry a one-time credential, a gift card's
code or a back-in-stock alert's single-use confirm link, that no platform event carries
(stored_value.gift_card.issued names the card, never its code). Every entry's replacementEventTypes names the
platform events your system sends that email from (order_placed → ['order.created']); where it
is null no emitted event replaces the email and none is refused with NO_REPLACEMENT_EVENT
(ValidationError), so a store never silently loses an email. An unknown event is NOTIFICATION_EVENT_NOT_FOUND
(NotFoundError). password_reset, email_change, account_setup, gift_card_issued and
stock_notification_confirm cannot be switched off: each is the only way a customer resets a
password (or sets the first one on an imported account), confirms a new address, finishes a
storefront registration, receives the code of a gift card they paid for (kept only as a hash), or
confirms a back-in-stock alert as a shopper who is not signed in (in a store that asks for it). enabled: false on them is refused with
EVENT_ALWAYS_ENABLED (ValidationError, the message naming the event's reason), and list
always answers them enabled. magic_link_login and customer_invite can be switched off. Both methods need UpdateSettings and act on the active channel.
The identity emails (the account category) and new_customer carry sign-in links or codes, and
one login serves the person in every store, so they go to the account holder alone, and so does
gift_card_issued, whose code spends the card for whoever holds a copy, and
stock_notification_confirm, whose single-use link confirms the alert of the address that asked: their
recipients are fixed (to the customer_email variable, cc and bcc empty), list answers
exactly that, and any other list is refused with IDENTITY_EMAIL_RECIPIENTS_FIXED
(ValidationError, details[].field naming each offending list). Resending what list answered
is accepted.
A store that takes settings.email from its organization (admin.tenant.settingsSources) sends the
emails the platform sends for it by the organization's enabled and recipients (the platform default
for an event the organization has none for), and list answers what applies. sender stays the
store's own, and an email your system already sends (sender: 'none') keeps the store's own entry
whole, so the organization never changes what your head must do. update refuses any change,
sender included, with ORGANIZATION_SETTINGS_INHERITED, or MANAGED_BY_ORGANIZATION when the
organization locked the group (both BusinessRuleError, 422); resending an entry unchanged is
accepted. Override the group to change them. The From address is always the store's own gateway's.
Storefront fonts and paths
const fonts = await admin.tenant.branding.fonts.list(); // the store's theme fonts
const inter = await admin.tenant.branding.fonts.create({
identifier: 'primary', kind: 'google', family: 'Inter', weights: [400, 700],
});
await admin.tenant.branding.fonts.update(inter.id, { weights: [400, 600, 700] });
await admin.tenant.branding.fonts.delete(inter.id); // @effect destructive
const paths = await admin.tenant.storefront.get();
await admin.tenant.storefront.set({
commercePathPrefix: '/shop',
commercePathPrefixTranslations: { nl: '/winkel', de: '' }, // de: no prefix at all
});A font is the store's own, never an organization's: another store's id reads as null and is
FONT_RESOURCE_NOT_FOUND (NotFoundError, 404) to update and delete. A value the engine
refuses is FONT_RESOURCE_INVALID (ValidationError, 400) and an identifier the store already
uses FONT_IDENTIFIER_TAKEN (ConflictError, 409); both name the field in details[0].field
(identifier, family, weights, assetIds). Reads need ReadSettings, writes UpdateSettings.
commercePathPrefixTranslations is the WHOLE map, decoded on read and encoded on write, keyed by
language code. A language left out uses commercePathPrefix; '' is "no prefix in this
language". A non-empty prefix (the default one and each language's) starts with /, a
translation key is a language code, and productPathSegment is one URL segment: the engine
refuses each with CHANNEL_SETTINGS_INVALID (ValidationError, 400) whose details[].field
names the setting and, for a translation, the language or key
(customFields.commercePathPrefixTranslations.nl). The default prefix and key rules judge only a
new or changed value, so a value stored before them still saves when sent back unchanged (a spread
of get). A patch with no setting,
an unknown key or a translation map that is not strings is STOREFRONT_SETTINGS_INVALID before any
request. set needs UpdateChannel.
Email style and previews
const style = await admin.tenant.emailStyle.get(); // what the store's emails use
await admin.tenant.emailStyle.set({ accentColor: '#1a2b3c' }); // a patch; the rest is kept
await admin.tenant.emailStyle.set({ ...style, buttonStyle: 'outline' }); // channelId is left out
const { subject, html } = await admin.tenant.emailTemplates.preview('order_confirmation', {
languageCode: 'nl',
style: { accentColor: '#ff6600' }, // unsaved; nothing is stored
});The style covers every email the store sends and the colours of its PDFs. Colours are a hex,
rgb(…), a colour name or transparent (EMAIL_STYLE_COLOR_INVALID otherwise); fontFamily,
logoPosition, logoVariant and buttonStyle name a member of a fixed set
(EMAIL_STYLE_FONT_FAMILY_INVALID, EMAIL_STYLE_LOGO_POSITION_INVALID, EMAIL_LOGO_VARIANT_INVALID,
EMAIL_STYLE_BUTTON_STYLE_INVALID; all ValidationError, 400), and a value the store already holds is
accepted back. A key that is not a style field is the engine's `BAD
