Online store
Crafty can sell for any site without a payment gateway: the merchant takes cash on delivery, payment at pickup or a bank transfer, and confirms the payment in the panel. Card payments through the merchant's own gateway (CardNET, AZUL) come in a later stage and plug into the same orders.
The frontend never computes prices and never touches payment data. It shows products, keeps a cart of slugs and quantities, asks Crafty for the totals, and sends the visitor to Crafty's hosted checkout (checkout.crafty.do/{store}), where they enter their details, pick delivery or pickup and a payment method, and get an order page and an email.
How it fits together
| Piece | Where | What it does |
|---|---|---|
| Plan | Crafty central (store_enabled) |
The workspace's plan must include the online store. |
| Store settings | Panel → site → Tienda tab, or the configure_store MCP tool (admins) |
Switch the store on per environment; currency, taxes, delivery, pickup, payment methods, order emails, return URL, checkout color, captcha. |
| Products | The productos collection (Panel → Tienda → Productos, or entries tools) |
One published entry per product. |
| Cart and totals | Your frontend, with the SDK | createCart() keeps the cart; crafty.orders.quote() prices it. |
| Checkout | checkout.crafty.do/{store}/c/{session} (hosted by Crafty) |
Customer details, delivery or pickup, payment method, captcha; places the order. |
| Order page | checkout.crafty.do/{store}/o/{number}?t={token} |
Status, lines, totals and payment instructions; linked from the emails. |
| Orders | Panel → Tienda → Pedidos | Confirm, mark ready, delivered or cancelled, mark paid, internal note, WhatsApp the customer. |
| Integrations | Content webhooks order.created, order.updated, order.status_changed, order.paid |
Notify other systems (ERP, digital delivery, Slack). |
1. Switch the store on
In the panel: site → Tienda → choose the environment → Activar tienda. Through MCP (admins):
configure_store {
"site_id": 3,
"enabled": true,
"currency": "DOP",
"pickup": { "enabled": true, "address": "Av. Sarasota 55, Santo Domingo" },
"delivery": { "enabled": true, "fee": "250", "free_from": "4000", "tax": "itbis-18", "zone": "Gran Santo Domingo" },
"payment_methods": { "cash_on_delivery": true, "pay_at_pickup": true, "transfer": true },
"transfer_accounts": "Banco Popular · Cuenta corriente 812-345678-9 · Café Monteverde SRL",
"notify_emails": ["[email protected]"],
"return_url": "https://monteverde.do/tienda"
}
Switching it on:
- creates the
productoscollection, or adopts an existingproductoscollection when its store fields have the types below (otherwise it reports the conflicting fields); - gives the environment a store handle and its checkout URL (
checkout.crafty.do/<site-slug>for production,<site-slug>-<environment>for others); - sets an order prefix from the site's initials (
CM-→CM-1001) and the Dominican tax template (ITBIS 18 %, ITBIS 16 %, Exento).
Switching it off stops new orders; products and orders stay. Send configure_store with only site_id to read the current settings, the checkout URL and how many products have no price.
2. Products
Products are entries of productos. While the store is on, these fields can be relabeled but not removed or retyped (pricing reads them), and the collection can't be deleted. Add any other fields you need: they never change the price.
| Field | Type | Notes |
|---|---|---|
nombre |
string, required | Product name (also the entry title and slug source). |
precio |
decimal, required | Price in the store currency, e.g. 1450 or 1450.50. Taxes included unless the store says otherwise. |
disponible |
select, required | disponible, pocas (few left) or agotado (sold out: can't be bought). |
variantes |
repeater | Each variant: nombre (required), precio (only when it changes the price), sku. A product with variants must be bought with one. |
imagenes |
multiReference → Media | The first image appears on the checkout and the order. |
sku |
string | |
impuesto |
string | A tax key of the store (itbis-18, itbis-16, exento). Empty uses the store's default tax. |
Only published products can be bought. Translations of nombre (via upsert_translation / translate_content) show on the checkout in the visitor's language.
3. The cart and totals in the frontend
Store calls need siteSlug in the client config. The API key, when sent, picks its environment; without it the store uses production.
import { createCraftyClient } from "@craftware/crafty-sdk";
import { createCart } from "@craftware/crafty-sdk/cart";
const crafty = createCraftyClient({ baseUrl, apiKey, siteSlug: "monteverde" });
const cart = createCart({ key: "monteverde:cart" });
const store = await crafty.store.get();
// { enabled, currency, prices_include_tax, taxes, delivery, pickup, payment_methods, checkout_url, … }
const { data: products } = await crafty.entries.list("productos", { per_page: 100 });
cart.add("geisha-lavado", { variant: "Molido para filtro", quantity: 2 });
cart.subscribe((items) => renderCartBadge(items));
const quote = await crafty.orders.quote(cart.items(), { fulfillment: "delivery" });
// quote.items, quote.subtotal, quote.delivery_fee, quote.taxes, quote.total (decimal strings)
// quote.unavailable: lines Crafty left out, with reason not_found | sold_out | variant_not_found | variant_required | no_price
Show quote.total, not a total you add up: Crafty applies variant prices, the free-delivery threshold and rounds each tax once per order, exactly as the order will be charged. crafty.store.get() is cached for a minute; quote() is always fresh.
4. Checkout
await crafty.checkout.start(cart.items(), {
returnUrl: "https://monteverde.do/gracias", // optional; defaults to the store's return URL
cancelUrl: "https://monteverde.do/carrito", // optional: "Volver al carrito" before ordering; without it the link goes to returnUrl
fulfillment: "pickup", // optional: the cart's choice, selected when the checkout opens
fields: { dedicatoria: "Feliz cumpleaños" }, // optional: prefills the checkout's own fields by key
});
start() creates a checkout session (valid 24 hours) and sends the browser to the hosted checkout. checkout.create() returns { id, url, expires_at } without redirecting, for SSR or links. The checkout re-prices the cart; if a price changed since the page loaded it shows the new total and asks the customer to confirm.
After the order, the order page shows a Volver a {tienda} button to the return URL with ?order=<number>, and goes there on its own after three minutes. On that page call cart.clearOnReturn(): it empties the cart and returns the order number so you can thank the customer.
A visitor may close the tab instead. To empty the cart anyway, remember the session when the checkout starts and check it on every page load:
await crafty.checkout.start(cart.items(), { onSession: (session) => cart.rememberCheckout(session.id) });
// on page load
await cart.syncCheckout(crafty); // empties the cart once that checkout became an order
syncCheckout() asks GET /api/v1/sites/{site}/checkout/{id} ({ placed } only) and keeps the cart when its lines changed after the checkout started.
An order can be read back with its token, which is in the order page link (?t=):
const order = await crafty.orders.get("CM-1001", token);
Discount coupons
The merchant creates codes in the panel (Cupones), with upsert_coupon or POST /api/v1/coupons. One coupon per order:
- Types:
percent(with an optionalmax_discount),fixed(an amount) orfree_delivery. - A coupon applies to all products or to chosen ones (
applies_to: "entries"plus product slugs). - Optional limits:
min_subtotal(the product subtotal before delivery),max_uses,max_uses_per_email,starts_atandends_at(whole days in store time), andis_active.
The visitor types the code on the hosted checkout. It shows the discount, or the reason the code doesn't apply.
- The discount is spread over the lines before taxes.
- "Free delivery from" looks at the subtotal after the discount.
- A cancelled order gives its use back.
- An order with nothing left to pay is marked paid with
payment_method: "none".
To carry a code from a shared link (https://monteverde.do/?cupon=BIENVENIDA10) to the checkout:
cart.captureCoupon(); // on page load: keeps ?cupon= (or ?coupon=) in the cart
const quote = await crafty.orders.quote(cart.items(), { coupon: cart.coupon() });
// quote.discount_total, quote.delivery_discount, quote.coupon, quote.coupon_error { reason, message, missing }
await crafty.checkout.start(cart.items(), { coupon: cart.coupon() });
Show quote.coupon_error.message as it is: it is already in the visitor's language. Possible reason values:
not_foundpausednot_startedexpiredexhaustedused_by_emailminimum(withmissing, the amount the cart still needs)not_eligibletoo_many_attempts
Failed codes are rate limited per visitor. The cart forgets the code once the order is placed. Orders, webhooks and orders.get() carry discount_total, delivery_discount, coupon and each line's discount.
5. Orders and notifications
- Each order gets a number per environment (
<prefix>1001,1002, …), status Nuevo, payment Pendiente. - Status moves forward only: new → confirmed → ready → delivered; any open order can be cancelled.
- Marcar pagado records the payment (with an optional reference, e.g. the transfer number).
- The customer gets an email on each step (from the store's name, replies go to the merchant); the
notify_emailsget every new order. Turn customer emails off withcustomer_emails: false. - Editors see Pedidos only with the
orderssection permission (hidden, read or edit), set per user in Settings → Users. - Editar (panel),
update_order(MCP) orPATCH /api/v1/orders/{number}change lines, quantities, delivery, payment method, customer, address, checkout fields and metadata while the order is unpaid and neither cancelled nor delivered; anaddresssent with only some keys changes just those. Lines already sold keep their price; new lines take today's price; delivery and taxes are recomputed. Each edit is saved in the order's history, emails the customer (unlessnotify: false) and fires theorder.updatedwebhook. Metadata can still change on a paid order, without emailing. - Orders can be created in the panel (Nuevo pedido, e.g. a phone order), with
create_orderorPOST /api/v1/orders. Theirsourceispanel,mcporapi; required checkout fields are not enforced there. - Exportar CSV downloads the orders matching the list's filters (UTF-8, opens in Excel): lines, totals, checkout fields, metadata and internal note.
- Customer emails carry the store's logo and color; Crafty only appears as a small gray mark at the bottom.
Checkout fields, look and analytics
Set in the panel (Tienda) or with configure_store:
checkout_fields: up to 15 extra fields asked before payment, with the form field types (text,textarea,email,tel,number,select,checkbox,radio,date). Each haskey,label,type,required,show(always,deliveryorpickup), optionalhelp_text,options(select/radio) andtranslationsper locale ({ en: { label, help_text, options } }). Each order keeps a copy (fields: [{ key, label, type, value }]), shown in the panel, the emails, the CSV and webhooks. Select values are stored in the default language.logo_media_id(an image of the site's media),color,font(system,inter,dm-sans,poppins,lora,playfair) andcorners(square,rounded,soft) theme the checkout, the order page and the customer emails.checkout_analytics: trueloads the environment's GA4 and Meta Pixel (fromconfigure_analytics) on the checkout, firingbegin_checkout/InitiateCheckoutand, once per order,purchase/Purchasewith the order number as transaction id. Tag Manager, TikTok and custom head code never run on the checkout, so a site's scripts can't read customer data there. The order page itself loads no analytics, because its URL carries the order's token:purchasefires on a short page in between, and a used checkout link then no longer opens the order. Add the checkout domain to GA4's cross-domain list to keep sessions joined.
REST endpoints
No API key is required; the site is in the URL. Errors use the usual { error, message } shape.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/sites/{site}/store |
Public settings. |
| POST | /api/v1/sites/{site}/orders/quote |
Body { items: [{ slug, variant?, quantity }], fulfillment?: "delivery" | "pickup", locale?, coupon?, email? }. |
| POST | /api/v1/sites/{site}/checkout |
Body { items, return_url?, cancel_url?, fields?, fulfillment?, locale?, coupon? } → 201 { data: { id, url, expires_at } }. |
| GET | /api/v1/sites/{site}/orders/{number} |
Token in ?token= or X-Order-Token. |
| GET | /api/v1/sites/{site}/checkout/{id} |
{ data: { placed } }: whether the session became an order; 404 once pruned. |
A closed store answers 404 STORE_DISABLED. The quote and checkout endpoints are rate limited per visitor.
Authenticated orders API
For an ERP, POS or backend: an OAuth token (the same as MCP) with crafty:read to read and crafty:write to create or change; editors need the orders permission. Pass site (slug) and optionally environment (default production) in the query or body.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/orders |
Filters status, payment_status, metadata[erp_id]=… (up to 5 pairs), per_page (max 100). |
| POST | /api/v1/orders |
Body like create_order: customer, fulfillment, address?, payment_method, items, fields?, metadata?, locale?, expected_total?, coupon? → 201. |
| GET | /api/v1/orders/{number} |
The order with payment_url (the customer's order page), fields, metadata, next_statuses and events. |
| PATCH | /api/v1/orders/{number} |
Edit keys (items with id for existing lines, quantity: 0 removes, coupon to add, keep or remove a code with null, coupon_below_minimum: "keep" | "remove"), plus status, paid, payment_reference, internal_note, notify. Returns the order and applied. |
| GET | /api/v1/coupons |
The environment's coupons with status, uses, orders, sales and discounted. Editors need the coupons permission. |
| POST | /api/v1/coupons |
Body like upsert_coupon: code, type, value, max_discount?, min_subtotal?, applies_to?, products?, max_uses?, max_uses_per_email?, starts_at?, ends_at?, is_active? → 201. |
| GET / PATCH / DELETE | /api/v1/coupons/{code} |
Read, change (only the keys sent) or delete. A used coupon keeps its code and type, and DELETE pauses it instead. |
Rejections use { error, message } with the code (PRICE_CHANGED is 409 and includes the new quote).
Starter
npm create crafty -- --template=astro-store creates a store site in Astro: catalog, product pages with variants, a cart page with live totals and the hosted checkout. It creates the productos collection and demo products, and switches the store on when the workspace plan includes it.
Common pitfalls
- Prices or taxes computed in the frontend. Always show
quote()totals; the checkout charges Crafty's numbers. - Products as drafts. Only published products can be bought; a static build lists only published entries too.
- A product with variants added without one. It comes back in
unavailablewithvariant_required. - Checkout 404. The store is off, the plan doesn't include it, or the session expired (24 h): start a new checkout.
- Moving the site to another workspace. Orders and the checkout handle move with it.