Skip to content

commerce

A shared base plus four provider clients, all raw fetch + crypto.subtle—no SDKs, no peers. See the Commerce guide for the how and why.

Fourthwall is two of the four, split along its trust boundary: /commerce/fourthwall speaks the public-safe Storefront API, and /commerce/fourthwall-platform the server-only Platform API.

The primitives every provider client shares: a money shape and the webhook signature crypto. Import them directly if you verify a custom provider’s webhook.

import {
centsToMajor,
hmacSha256Hex,
hmacSha256Base64,
safeEqual,
type Money,
} from "louise-toolkit/commerce";
Export Purpose
Money { amount, currency }—amount in the currency’s minor unit (cents).
centsToMajor(cents) Minor units → major (250025).
hmacSha256Hex / hmacSha256Base64 HMAC-SHA256 of a message under a secret (Stripe uses hex; Square/Fourthwall use base64).
safeEqual(a, b) Constant-time-ish compare—use it to check a computed signature against a header value.
import {
createPaymentIntent,
retrievePaymentIntent,
verifyStripeSignature,
ensureStripeCustomer,
createAndSendInvoice,
createLineItemInvoice,
type CartItem,
type InvoiceLineItem,
type StripeAddress,
} from "louise-toolkit/commerce/stripe";
Export Purpose
createPaymentIntent(secretKey, items, …) Create a PaymentIntent over a multi-item cart.
retrievePaymentIntent(secretKey, id) Re-fetch a PaymentIntent (webhooks treat events as pointers).
verifyStripeSignature(body, header, secret) Verify a webhook signature before trusting the payload.
ensureStripeCustomer(secretKey, …) Reuse-or-create a customer.
createAndSendInvoice(...) / createLineItemInvoice(...) Hosted invoices with line items and automatic tax (when the customer has an address).

The Stripe API version is pinned in the module so an account-default upgrade can’t silently change response shapes—bump it deliberately.

import {
listCollections,
getCollectionProducts,
getProduct,
listCatalog,
lowestPrice,
createCart,
verifyFourthwallSignature,
type FwProduct,
type FwVariant,
type FwCartItem,
} from "louise-toolkit/commerce/fourthwall";
Export Purpose
listCollections(token) / getCollectionProducts(...) Browse the storefront catalog.
getProduct(token, slug) Fetch a single product (or null).
listCatalog(...) The catalog list used to sync a product overlay.
lowestPrice(product) Cheapest variant price, for “from $X” display.
createCart(token, items) Create a cart; hand off to Fourthwall hosted checkout.
verifyFourthwallSignature(...) HMAC-verify an inbound order webhook.

The Fw* interfaces (FwProduct, FwVariant, FwImage, FwMoney, FwStock, FwCollection, …) type the storefront payloads.

louise-toolkit/commerce/fourthwall-platform

Section titled “louise-toolkit/commerce/fourthwall-platform”

The Platform API (Open API v1.0)—at-cost fulfillment orders and product creation. A separate subpath from /commerce/fourthwall, and the split is deliberate.

import {
validateExternalOrder,
createExternalOrder,
listExternalOrders,
getExternalOrder,
cancelExternalOrder,
isCancellable,
getProductInventory,
createProduct,
deleteProduct,
setProductAvailability,
setProductState,
addProductImages,
type FourthwallPlatformConfig,
} from "louise-toolkit/commerce/fourthwall-platform";
Export Purpose
validateExternalOrder(...) Price an order without creating it. Call this first.
createExternalOrder(...) Place it. Chargeable, and never retried—see below.
listExternalOrders(...) Paged list, optionally filtered by status.
getExternalOrder(...) One order, or null when it doesn’t exist.
cancelExternalOrder(...) Cancel. Refused once PACKAGED/SHIPPED.
isCancellable(order) Local check, so a UI can hide the button instead of throwing.

validateExternalOrder is the only place the at-cost breakdown—manufacturingCost, fulfillmentFee, shippingCost, totalCreatorCost—is available before money is committed. Shipping especially isn’t knowable up front: it depends on the destination and on how Fourthwall splits the items across facilities.

createExternalOrder never retries, even when config.retry is set. Fourthwall has no idempotency-key header, so a retried create that actually succeeded server-side is a second order and a second charge. A sync job that turned retries on globally must not silently inherit that. For at-most-once across a queue redelivery, set externalId and reconcile with listExternalOrders before creating.

Export Purpose
createProduct(...) Create. Throttled—5/min per shop.
deleteProduct(...) Permanent, and the only way to “edit” one.
setProductAvailability(...) Shop-level purchasable switch.
setProductState(...) The product’s own lifecycle state.
addProductImages(...) Appends. There is no replace.
getProductInventory(...) Read-only—there is no inventory write.

createProduct takes a discriminated input. Physical products are priced by profitMargin, not by retail price—you choose what you make per unit and Fourthwall derives the price. Only digital products take an absolute price:

await createProduct(config, { kind: "physical", name: "Tee", profitMargin: 8 });
await createProduct(config, {
kind: "digital",
name: "Zine",
price: { value: 5, currency: "USD" },
});

getProductInventory returns quantity: null for a variant that isn’t stock-tracked—distinct from 0, and collapsing them hides a sellable variant. There’s also no inventory webhook, so stock drift is only detectable by polling. Pick an interval against how bad an oversell is for you, not against how fresh you’d like the number to be.

On by default. A token bucket per shop, refilling continuously rather than resetting on a window boundary—a fixed window lets 2× the limit through across the boundary, which is the exact burst a limiter is for.

Limit Default
Global, all endpoints 100 / 10 s
POST /products 5 / minute

Both are counted per shop, so adding API users buys no extra budget. The buckets key on rateLimitKey, which defaults to username—right for one user per shop, wrong for several, where each would get its own bucket and the group would overrun the real limit together. Give every client for a shop the same string.

POST /products also runs a synchronous mockup render, so it’s slow as well as rare. A bulk import of 50 products takes ten minutes by design; the alternative is 45 of them erroring.

Pass rateLimit: false to opt out, or override either number to go lower. Raising it doesn’t raise the server’s limit—it just moves where you find out.

Square exposes a single versioned REST surface (/v2/*). The whole client is injected through a SquareConfig and pins Square-Version.

Retry is off by default, and that is a decision about who is waiting. Square publishes no per-endpoint rate limits—only “RATE_LIMITED, HTTP 429, back off exponentially”—so SquareConfig.retry (attempts, baseDelayMs, maxDelayMs) handles 429 and 5xx with jitter inside every fetch verb. Leaving it off keeps an attended path honest: on a checkout route a caller is watching a spinner, and three silent retries turn a fast failure into a slow one. Turn it on for unattended work—the queue consumer’s catalog refresh, a cron sync, any multi-location push—where the failure mode without it is a half-applied catalog and a second of backoff costs nobody anything.

const square = { accessToken, environment, retry: { attempts: 3 } };

A 4xx other than 429 is never retried: that is our bug, not Square’s weather.

Square documents a silent data-loss hazard, verbatim: “If a client reads an object at an older API version and writes it back at a newer version, fields that were introduced between those two versions will be absent from the request, and the server will interpret that absence”—as an intentional clear.

The same hazard applies to any read-modify-write that rebuilds the object from the fields it happens to model. readModifyWriteCatalog never rebuilds: it reads the raw object, hands it to your mutator, and writes back what it got, carrying the version Square returned and the same pinned Square-Version on both calls.

await readModifyWriteCatalog(config, "VAR123", (object) => {
const data = object.item_variation_data as Record<string, unknown>;
data.price_money = { amount: 1800, currency: "USD" };
});

The version always comes from that read, so a concurrent write makes yours fail rather than silently overwrite. Reach for upsertCatalogItem when creating or wholesale-replacing an item, and this when touching one field of something that already exists—which is exactly when accidental erasure is likeliest and least visible.

import {
SQUARE_VERSION,
centsToMajor,
listCatalogItems,
retrieveCatalogItem,
retrieveVariationPrices,
retrieveInventoryCounts,
createOrder,
retrieveOrder,
searchOrdersByCustomer,
createPayment,
searchCustomersByEmail,
retrieveCustomer,
createCustomer,
ensureCustomer,
createCard,
retrieveLoyaltyAccountByCustomer,
searchSubscriptionsByCustomer,
createSubscription,
verifySquareSignature,
type SquareConfig,
type SquareCatalogItem,
type SquareOrder,
type SquarePayment,
type SquareCustomer,
type SquareSubscription,
} from "louise-toolkit/commerce/square";
Area Exports
Config SquareConfig (accessToken, environment, version, retry), SquareRetryConfig, SQUARE_VERSION, centsToMajor
Locations listLocations, retrieveLocation, createLocation, updateLocation (sparse), SquareLocation, SquareLocationInput
Catalog images createCatalogImage—multipart upload returning the id that imageIds takes
Catalog listCatalogItems, retrieveCatalogItem, retrieveVariationPrices, mapCatalogItem
Catalog (write) upsertCatalogItem, batchUpsertCatalogObjects—per-location pricing via locationOverrides, presence via presentAt / priceAtLocation. Both refuse a variation sold where its item isn’t, and an item over Square’s 250-variation cap.
Catalog (edit) readModifyWriteCatalog(config, id, mutate)—edit one field of an existing object without erasing the ones this client doesn’t model. Use it over hand-rolling a read/write pair; see below.
Inventory retrieveInventoryCounts, batchChangeInventory, setPhysicalCount
Orders createOrder, retrieveOrder, calculateOrder (price a cart without persisting it), searchOrdersByCustomer, searchOrders (date/state/location filters, cursor-paged, chunked at Square’s 10-location ceiling)
Payments createPayment—charge a Web Payments card token against an order.
Customers searchCustomersByEmail, retrieveCustomer, createCustomer, ensureCustomer
Cards & subscriptions createCard, searchSubscriptionsByCustomer, createSubscription
Loyalty retrieveLoyaltyAccountByCustomer
Webhooks verifySquareSignature(url, body, header, key)—note the URL is signed too.

The Square* interfaces (SquareCatalogItem, SquareVariation, SquareOrder, SquarePayment, SquareCustomer, SquareCard, SquareLoyaltyAccount, SquareSubscription, SquareMoney, …) type the normalized, camelCase shapes the client returns. SquareMoney is an alias of the shared Money, and centsToMajor is re-exported from the shared base—both still import from louise-toolkit/commerce/square.