Products, stock, orders and bulk import
The merchant commerce surface: variants, cost-plus pricing, stock movements, cart orders, and importing a catalog from a spreadsheet.
The ecosystem guide covers onboarding a client and publishing a simple offering. This guide covers the merchant commerce surface built on top of it: variants, cost-plus pricing, stock, cart orders, and importing a whole catalog from a spreadsheet. Everything here is available to a merchant's own token through the same requireClient authority as the rest of /v1/clients/{clientId}.
The model in one paragraph
An item is a product. Every product owns one or more variants, and the variant is what a shopper buys: it carries the SKU, the price, the cost, the stock. A product without options (a mug) has one default variant with no option values, and the portal hides it. A product with options (a tee in three sizes) lists its option names once on the product and one value per option on each variant. offerings.amount is kept equal to the lowest live variant price so every older reader still sees "the price".
Price a product
A variant is priced in one of two modes.
manual: the amount is what you send.
cost_plus: you send a cost_amount and the sell price derives from the most specific markup found: the variant's own, then the product's, then the shop's default. A markup is {"kind": "percent", "value": 2500} (basis points of the cost) or {"kind": "fixed", "value": 150} (minor units on top). The shop also sets a rounding increment and offset, so a derived 12.34 can land on 13.00 or 12.99.
# Shop defaults: 25% on cost, rounded up to whole units minus one minor unit (x.99)
curl -sS -X PUT "$WALLETD_API/v1/clients/$CLIENT_ID/pricing" \
-H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \
-d '{"markup": {"kind": "percent", "value": 2500}, "rounding": {"increment": 100, "offset": -1}}'The response carries repriced_variants: how many cost-plus variants moved because of this change. Changing a markup at any level reprices everything below it that has no more specific override, in one transaction.
Every price change, including the first, is a row in the price history:
curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/price_history?offering_id=$OFFERING_ID" \
-H "Authorization: Bearer $FOUNDER_TOKEN"A published item may be repriced; the history is the record. A published plan may not, because subscribers are already paying it.
Create a product with variants
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings" \
-H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \
-d '{
"kind": "item", "title": "Logo tee", "options": ["Size", "Colour"],
"variants": [
{"sku": "TEE-S-BLK", "option_values": ["S", "Black"], "amount": 2000, "track_inventory": true, "initial_stock": 10, "low_stock_threshold": 2},
{"sku": "TEE-M-BLK", "option_values": ["M", "Black"], "amount": 2000, "track_inventory": true, "initial_stock": 12},
{"sku": "TEE-M-WHT", "option_values": ["M", "White"], "pricing_mode": "cost_plus", "cost_amount": 900, "track_inventory": true}
]
}'Later variants go through POST .../offerings/{offeringId}/variants, edits through PUT .../variants/{variantId}, and retirement through POST .../variants/{variantId}/archive. A product keeps at least one variant; archive the product instead. A SKU names one live variant per shop and an archived variant frees its SKU.
Stock
Stock lives on the variant: track_inventory, on hand, reserved, allow_backorder, and a low_stock_threshold. Available is on hand less reserved. A variant that does not track inventory is unlimited (digital goods, services).
Every count change is a movement with a reason, and the log is append-only:
# Received a delivery
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/offerings/$OFFERING_ID/variants/$VARIANT_ID/stock" \
-H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \
-d '{"delta": 24, "reason": "delivery 4471"}'
# Counted the shelf
curl -sS -X POST ".../variants/$VARIANT_ID/stock" ... -d '{"set_to": 31, "reason": "stock take"}'
# The log
curl -sS "$WALLETD_API/v1/clients/$CLIENT_ID/inventory/movements?variant_id=$VARIANT_ID" -H "Authorization: Bearer $FOUNDER_TOKEN"Movement kinds: adjustment and import (you), reservation, release and sale (orders), restock (after a refund). Manual adjustments never take stock below zero; a backordered sale does, and negative on hand is how many units you owe.
Shoppers never see counts. Explore shows each variant's inventory.in_stock and inventory.low_stock and nothing else.
Orders
A shopper buys with a cart from one merchant. Creating the order reserves stock; paying it sells; canceling or expiry releases.
# Reserve (idempotent by key). Prices come from the catalog; never send an amount.
curl -sS -X POST "$WALLETD_API/v1/orders" \
-H "Authorization: Bearer $CONSUMER_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: cart-9f1e" \
-d '{"lines": [{"variant_id": "'$VARIANT_ID'", "quantity": 2}]}'
# Pay. Idempotent by order: call it again and you get the same payment.
curl -sS -X POST "$WALLETD_API/v1/orders/$ORDER_ID/pay" -H "Authorization: Bearer $CONSUMER_TOKEN"An order is open for 15 minutes, then a sweep expires it and releases its stock. 422 insufficient_stock names the line that cannot be promised. A payment refusal (422 insufficient_funds) leaves the order open so the shopper can top up and pay again.
The merchant reads its book at GET /v1/clients/{clientId}/orders, may cancel an open order, and after refunding a paid one may put its units back with POST .../orders/{orderId}/restock (once; damaged goods do not come back).
The older POST /v1/explore/offerings/{offeringId}/purchase still works for a product with a single variant: it is an order of one unit and answers with the payment, so stock is sold the same way.
Each line records the commission the loop takes, resolved offering, then client, then loop default exactly as a single purchase does, and the payment carries the sum.
Bulk import
Download the template, fill it, and upload it with an optional ZIP of pictures:
- catalog-import-template.xlsx (with a help sheet)
- catalog-import-template.csv
- catalog-import-sample-images.zip
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/imports" \
-H "Authorization: Bearer $FOUNDER_TOKEN" \
-F "file=@products.xlsx" -F "images=@pictures.zip"The answer is 202 with an import in status validating. Poll GET .../imports/{importId} until it is ready, read the preview at GET .../imports/{importId}/rows (each row says create, update or error and why), then POST .../imports/{importId}/commit. Nothing lands before the commit. POST .../cancel drops it.
Rules that matter:
- Rows match existing products by SKU. Re-importing the same file updates instead of duplicating.
- Rows sharing a
product_handleare variants of one product and must list the same option names in the same order. A row'simagenames a file in the ZIP;image_urlis an https link we fetch (public addresses only, 5 MB, png/jpeg/webp/gif). Give one or the other. - Money columns are decimals in the shop's currency with at most two places (
12.50).markup_valueis a percentage (25or12.5) forpercentand an amount forfixed. - A blank cell on an update keeps the current value.
stockon an update sets the count and logs animportmovement. status: publishedneeds an approved shop; otherwise the product stays a draft and the row says so.- Limits: 5,000 rows, a 10 MB sheet, a 100 MB ZIP. Bad rows never block good ones; they are listed and left out.
Imports run on their own queue and finish with an import.completed webhook.
Connect your own system
A merchant can issue API keys that act as its business, from the portal's Developers page or over the API:
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/api_keys" \
-H "Authorization: Bearer $FOUNDER_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "POS till 2"}'The answer carries the token once. Send it as a bearer on every /v1/clients/{clientId} route above, and on /v1/explore/*. A bound key sees and changes only its own business: another merchant's routes answer 403 not_client_member, and tenant-wide routes (the loop's webhook endpoints, the fee plan, other merchants) are out of reach because the key carries no tenant-wide scope. Only a signed-in member of the business may issue or revoke keys; a key cannot mint keys. Revoke a leaked key with DELETE .../api_keys/{keyId}; allow for both authsvc and walletd verification caches (approximately two minutes plus request latency with defaults), then verify rejection. See Security for integrators.
Hear about orders and stock
The same key registers webhook endpoints for the business:
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/webhook_endpoints" \
-H "Authorization: Bearer $MERCHANT_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://api.yourshop.com/walletd", "event_filters": ["order.paid", "order.canceled", "inventory.low_stock"]}'The signing secret comes back once. A merchant endpoint receives product.*, order.*, inventory.* and import.* events for this business and nothing else; verify every delivery as the webhooks guide shows. GET .../webhook_endpoints lists them, DELETE .../webhook_endpoints/{endpointId} stops one, and GET .../webhook_deliveries is the attempt log. An order.paid delivery is the moment to fulfil: its data.order.lines name the variants and quantities sold at the prices charged.
Rewards on a mixed cart
Loyalty and cashback rules are evaluated per order line. A cart of several products earns each product's own rule on that product and the merchant's common rule on the rest, so a per-product promotion pays exactly on the product it names, however the shopper mixed the cart.
The ecosystem: clients, offerings, discovery
Many businesses in one shared consumer loop: clients, offerings, discovery, what a sale earns, and client-funded rewards.
Webhooks
Signed, at-least-once deliveries that tell your backend money moved, and how to verify one in curl, Go, Python, PHP, JavaScript and Java.