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.
louise-toolkit/commerce (shared base)
Section titled “louise-toolkit/commerce (shared base)”The primitives every provider client shares: a money shape, its conversions and formatting, and the webhook signature crypto. Import them directly if you verify a custom provider’s webhook.
import { centsToMajor, formatMoney, parseMoney, hmacSha256Hex, hmacSha256Base64, safeEqual, type Money,} from "louise-toolkit/commerce";| Export | Purpose |
|---|---|
Money |
{ amount, currency }—amount in the currency’s minor unit (cents). |
centsToMajor(cents, digits?) |
Minor units → major (2500 → 25). The default of 2 digits suits USD, not JPY or BHD. |
currencyDigits(currency) |
A currency’s minor-unit count from Intl: 2 for USD, 0 for JPY, 3 for BHD. |
formatMoney(money, { locale }) |
A Money as text ("$1,250.00"). Other Intl.NumberFormat options pass through. |
parseMoney(text, { locale, currency }) |
What formatMoney prints ("$1,200.50", "1.200,50 €") → minor units, or null. |
majorToCents(amount, digits?) |
Major → minor, exactly. Math.round(1.005 * 100) is 100; this gives 101. |
parseMoneyInput(text, digits?) |
A typed amount ("12.50") → minor units, or null. Parsed as text, strict, no float. |
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. |
Checking a cart against the live catalog
Section titled “Checking a cart against the live catalog”A stored cart outlives the catalog it was built from. Refuse a checkout that disagrees with the catalog—but report every problem at once. A check that stops at the first leaves the customer fixing one line per retry.
import { cartIssues, cartModifierIds, repairCart } from "louise-toolkit/commerce";import { retrieveLiveCatalogObjectIds, retrieveVariationPrices,} from "louise-toolkit/commerce/square";
const prices = await retrieveVariationPrices( config, lines.map((l) => l.variantId),);const issues = cartIssues(lines, { prices: new Map([...prices].map(([id, m]) => [id, m.amount])), liveModifierIds: await retrieveLiveCatalogObjectIds(config, cartModifierIds(lines), { type: "MODIFIER", }),});if (issues.length) return json({ issues }, 409); // the client repairs, then retriescartIssues returns one entry per variant or add-on: price-changed (with the
price now), unavailable, out-of-stock (checked before price, since a sold-out
variant is usually still priced), or modifier-unavailable. It compares a cart
you have already validated; it doesn’t police input.
On the client, repairCart(lines, issues, { maxQuantity?, key? }) applies all of
them in one step. It reprices, removes, strips deleted add-ons, and combines any
two lines that became identical. It returns { lines, changes }, where changes
is data—repriced, removed, modifier-removed, merged (with any quantity the
cap cut off)—for you to word for your customers. It never mutates its input, and
it applies no quantity cap unless you pass one.
louise-toolkit/commerce/stripe
Section titled “louise-toolkit/commerce/stripe”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.
louise-toolkit/commerce/fourthwall
Section titled “louise-toolkit/commerce/fourthwall”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, FwAdditionalInformation, …) type the storefront payloads.
Mirroring a Fourthwall catalog
Section titled “Mirroring a Fourthwall catalog”Three facts about Fourthwall’s API bite any sync that mirrors it:
fourthwallCopy(product, { panel?, onComplianceDropped? }): the copy worth showing.descriptionis often empty, because sellers type into the More details panel instead. That panel also carries a hidden EU GPSR compliance block with Fourthwall’s fulfilment address, which Fourthwall’s own storefront never shows. This helper returns More details with that block removed, falling back todescription. If compliance text survives the strip, the panel is dropped whole, andonComplianceDroppedis called so you can log it. HTML out—sanitize it before rendering.catchAllFirst(listCatalog(...)): Fourthwall returns the catch-all “All Products” collection last. A sync that sets a product’s category once per collection keeps the last one, so every product ends up in “All Products”. Moving the catch-all first gives real collections the final word.isCatchAllCollection(c)tests one.FW_IMAGE_HOST: the host of Fourthwall’s signed product-image URLs.
To find products Fourthwall dropped, use vanishedRows from the
shared base:
import { vanishedRows } from "louise-toolkit/commerce";
// only after a COMPLETE read, and never when it came back emptyif (seen.size > 0) { const gone = vanishedRows(stored, seen, { externalId: (r) => r.fourthwallProductId, alreadyMarked: (r) => r.missingAt !== null, });}It diffs in memory, because SQLite caps bound parameters and a big catalog would
break a NOT IN (…). The guard is yours: a revoked token or an endpoint answering
[] looks like “the shop has nothing”, and acting on it retires the whole store.
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";External orders
Section titled “External orders”| 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.
Products
Section titled “Products”| 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.
Rate limiting
Section titled “Rate limiting”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.
louise-toolkit/commerce/square
Section titled “louise-toolkit/commerce/square”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.
Every non-2xx answer throws a SquareApiError, an
UpstreamError carrying
status, Square’s code, and its category. Check err.status === 404 to tell
“not found” from a failure, since a 404 is often a real answer. A decline has
category: "PAYMENT_METHOD_ERROR": the buyer’s to fix, so map code to your own
copy. Its message is safe to show; Square’s own wording is on detail, for
logs. Set timeoutMs (default 10 seconds) to allow a slow bulk call longer.
Accounts: customers, cards, loyalty
Section titled “Accounts: customers, cards, loyalty”| Export | Purpose |
|---|---|
ensureCustomer(config, { email, …, phoneNumber? }) |
Find-or-create by email. The names and phone apply only when creating. |
updateCustomer(config, id, fields) |
Change fields on an existing customer. Sends only the fields you pass; null clears one. |
listCards(config, { customerId }) |
A customer’s cards on file, following the cursor. Enabled cards only unless includeDisabled. |
disableCard(config, cardId, { customerId }) |
Remove a card, only if it’s on file for that customer. Returns false otherwise. |
retrieveLoyaltyProgram(config) |
The seller’s program (earn rules, reward tiers, terminology), or null when there isn’t one. |
retrieveLoyaltyAccountByCustomer(config, id) |
A customer’s points balance and lifetime points, or null. |
squareApplicationIdEnvironment(appId) |
"sandbox", "production", or null, read from the application id’s format. |
disableCard reads the card first and checks that its customer matches, so a
guessed card id from one signed-in customer can’t remove another’s.
retrieveLoyaltyProgram returns the program as Square has it, inactive ones
included—check status before advertising it. It invents no terminology when
Square sends none. Only a 404 means “no program”; any other failure throws, so a
transient error isn’t cached as an answer.
Compare squareApplicationIdEnvironment(PUBLIC_SQUARE_APPLICATION_ID) with the
environment your server uses before mounting the card form. A placeholder id, or
an id from the other environment, otherwise fails inside the payment SDK with an
error a customer can’t act on.
Editing an existing object
Section titled “Editing an existing object”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 |
| Team | createTeamMember, updateTeamMember, retrieveTeamMember, searchTeamMembers, SquareTeamMember, TeamMemberInput |
| Labor | createTimecard (clock in), updateTimecard (clock out), retrieveTimecard, searchTimecards, SquareTimecard, TimecardWage |
| Invoices | createInvoice, publishInvoice, retrieveInvoice, SquareInvoice, InvoicePaymentRequestInput |
| 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.
Team, labor, and invoices
Section titled “Team, labor, and invoices”Team. searchTeamMembers filters by status (default ACTIVE) and
locationIds only, because the Team API has no email filter. It returns the
first page, up to limit (default 200). To link Square records to your own
users, set referenceId to your user ID when you create the team member, then
match on referenceId in the results.
Labor. A team member holds only one open timecard at a time.
updateTimecard replaces the whole record, and Square requires a wage on it,
so an update that omits wage fails. To clock someone out, read the timecard
first and send its wage and version back with the new endAt:
const card = await retrieveTimecard(config, timecardId);if (!card) throw new Error("No such timecard");await updateTimecard(config, card.id, { locationId: card.locationId, teamMemberId: card.teamMemberId, startAt: card.startAt, endAt: new Date().toISOString(), version: card.version, wage: card.wage ? { title: card.wage.title ?? undefined, hourlyRateCents: card.wage.hourlyRateCents, currency: card.wage.currency, } : undefined,});Square rejects a stale version, so a concurrent change fails your update
rather than being overwritten.
Invoices. createInvoice makes a draft invoice for an existing open order:
the order carries the line items and total, and the invoice adds the recipient
and a payment schedule. paymentRequests takes exactly one BALANCE as the
last request, preceded by an optional DEPOSIT, 2 to 12 INSTALLMENT
requests, or both. Omit amountCents on the BALANCE to cover whatever remains.
const draft = await createInvoice(config, { locationId, orderId, customerId, paymentRequests: [ { type: "DEPOSIT", dueDate: "2026-10-01", amountCents: 5000 }, { type: "BALANCE", dueDate: "2026-10-15" }, ],});const invoice = await publishInvoice(config, draft.id, draft.version);Nothing is collected until you call publishInvoice with the draft’s
version. With the default deliveryMethod of SHARE_MANUALLY, the
published invoice carries a publicUrl to Square’s hosted payment page for you
to send. Pass "EMAIL" to have Square email the customer instead. To reconcile
payments, call retrieveInvoice and read each payment request’s
totalCompletedAmountCents.