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, 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.

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 retries

cartIssues 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.

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, FwAdditionalInformation, …) type the storefront payloads.

Three facts about Fourthwall’s API bite any sync that mirrors it:

  • fourthwallCopy(product, { panel?, onComplianceDropped? }): the copy worth showing. description is 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 to description. If compliance text survives the strip, the panel is dropped whole, and onComplianceDropped is 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 empty
if (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";
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.

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.

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.

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. 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.