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 productos collection, or adopts an existing productos collection 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 optional max_discount), fixed (an amount) or free_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_at and ends_at (whole days in store time), and is_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_found
  • paused
  • not_started
  • expired
  • exhausted
  • used_by_email
  • minimum (with missing, the amount the cart still needs)
  • not_eligible
  • too_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_emails get every new order. Turn customer emails off with customer_emails: false.
  • Editors see Pedidos only with the orders section permission (hidden, read or edit), set per user in Settings → Users.
  • Editar (panel), update_order (MCP) or PATCH /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; an address sent 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 (unless notify: false) and fires the order.updated webhook. 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_order or POST /api/v1/orders. Their source is panel, mcp or api; 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 has key, label, type, required, show (always, delivery or pickup), optional help_text, options (select/radio) and translations per 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) and corners (square, rounded, soft) theme the checkout, the order page and the customer emails.
  • checkout_analytics: true loads the environment's GA4 and Meta Pixel (from configure_analytics) on the checkout, firing begin_checkout / InitiateCheckout and, once per order, purchase / Purchase with 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: purchase fires 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 unavailable with variant_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.