Checkout: Carts, Quotes, Shipping, Taxes, Discounts, Analytics
Carts
A cart is a server-side draft order identified by an opaque token (its primary lookup key, returned from creation). Carts have status: open → converted (after checkout) or abandoned (set on order cancellation or by the abandoned-cart cutoff).
POST /carts
Scope: checkout (publishable key OK).
Body (all fields optional):
| Field | Type | Constraints |
|---|---|---|
currency | enum | PLN|USD|EUR, defaults to store default |
language | enum | pl|en, defaults to store default |
items | array | [{ variant_id: uuid, quantity: int(1-10000) }], max 200 |
email | string|null | |
discount_code | string|null | max 60 |
shipping_address | address|null | see the address schema in Orders → POST /orders |
billing_address | address|null | |
shipping_rate_id | uuid|null | |
pickup_location_id | uuid|null | |
buyer_vat_id | string|null | max 30 |
note | string|null | max 2000 |
utm | record<string,string>|null |
Response 201 — cart object with live quote:
{
"data": {
"token": "ck_8f239a...",
"status": "open",
"currency": "PLN",
"email": null,
"language": "pl",
"items": [{ "variant_id": "a1b2...", "quantity": 2 }],
"discount_code": null,
"shipping_address": null,
"billing_address": null,
"shipping_rate_id": null,
"pickup_location_id": null,
"buyer_vat_id": null,
"note": null,
"order_id": null,
"quote": { "...": "Quote object, see below" },
"created_at": "...",
"updated_at": "..."
}
}GET /carts/:token
Scope: checkout. Returns the cart + freshly computed quote (also persists subtotal back onto the cart row if it drifted).
PATCH /carts/:token
Scope: checkout. Same body as POST, all fields optional/overwrite; sets status back to open (even if previously abandoned). 409 cart_converted if the cart was already checked out.
POST /carts/:token/items
Scope: checkout. Body: { variant_id: uuid, quantity: int(1-10000) }. Adds to existing line quantity if the variant is already present, else appends. Sets status to open.
DELETE /carts/:token/items/:variantId
Scope: checkout. Removes that variant from the cart's items.
POST /carts/:token/checkout
Scope: checkout. Converts the cart into a real order.
If the cart is already converted, returns the existing order instead of erroring (idempotent for this one case only).
Preconditions (else 422):
cart.emailmust be set → elseemail_required- Either
pickup_location_idset, orshipping_addresshasline1,city,postal_code,country_code→ elseaddress_required - Either
shipping_rate_idorpickup_location_idset → elseshipping_required
Body: { customer?: { first_name?, last_name?, company?, accepts_marketing? }, phone?: string|null (max 40) }.
If an X-Customer-Token header is sent and resolves to a valid, non-expired session, the resulting order is linked to that logged-in customer (customer_id).
Side effects: creates the order via the same logic as POST /orders (source "checkout"), marks the cart converted with order_id/customer_id, and inserts a purchase analytics_event (session_id from utm.session_id or the cart token, value=order total, plus source/medium/campaign from cart UTM data).
Response 201 — order object (non-secret serialization, i.e. no internal_note/tags/utm/confirmed_by).
Quote (price preview without creating anything)
POST /checkout/quote
Scope: checkout.
Body: { currency?: enum, lines: [{ variant_id, quantity }], discount_code?: string|null(max60), shipping_rate_id?: uuid|null, shipping_country?: string(len 2)|null, buyer_vat_id?: string|null(max30), email?: string|null(email) }.
Response — the Quote object:
{
"data": {
"currency": "PLN",
"lines": [
{
"variant_id": "a1b2...", "product_id": "6f2e...", "title": "Ceramic Mug", "variant_title": "White",
"sku": "MUG-WHT", "image_url": "https://your-openshopy-domain/api/public/media/...", "quantity": 2,
"unit_price": 2999, "unit_cost": 1200, "line_subtotal": 5998, "discount_amount": 600,
"total": 5398, "tax_rate": 23, "tax_amount": 1009, "weight_grams": 700
}
],
"subtotal": 5998,
"discount_total": 600,
"shipping_total": 1500,
"shipping_tax": 280,
"tax_total": 1289,
"total": 6898,
"prices_include_tax": true,
"tax_country": "PL",
"reverse_charge": false,
"export_zero_rated": false,
"applied_discounts": [{ "id": "d1...", "title": "Spring sale", "code": "SPRING10", "amount": 600, "type": "percentage" }],
"free_shipping": false,
"shipping": { "id": "r1...", "name": "Courier", "carrier_code": "dpd", "price": 1500, "original_price": 1500, "is_pickup": false, "delivery_days_min": 1, "delivery_days_max": 3, "zone": "Poland" },
"shipping_options": [ "...ShippingOption[]" ],
"total_weight_grams": 700,
"issues": []
}
}issues[] entries have { code, message, variant_id? }; blocking codes that would stop an actual order from being created are: variant_not_found, product_unavailable, insufficient_stock, shipping_unavailable, discount_invalid, discount_not_applicable (non-blocking example: none currently — all current issue codes are blocking for order creation, but the quote endpoint itself never errors, it just reports them).
Pricing, tax and discount rules:
- Only one non-combinable discount can apply (highest amount+free-shipping wins); all
combinable:truediscounts plus that one exclusive winner all stack. - Discount types:
percentage(of eligible subtotal, capped 100%),fixed(minor units, capped at eligible subtotal),free_shipping,bundle(requires everybundle_product_idspresent in cart, percent off those lines),volume(percent off oncevolume_tiersquantity threshold reached, highest qualifying tier wins). applies_to:all|products(matchesproduct_ids) |collections(matchescollection_ids).- Tax country resolution: if
store.tax_destination_basedand destination is in the EU, tax country = destination; else store's own country. - Reverse charge applies when: store has
reverse_charge_enabled, abuyer_vat_id(len > 4) is given, both store and destination are in the EU, and destination ≠ store country → zero tax. - Export zero-rating applies when: store has
zero_rate_exports, store is in the EU, destination is not in the EU, destination ≠ store country → zero tax. - Tax is computed per-line using the product's
tax_classand inclusive/exclusive math perstore.prices_include_tax.
Shipping
GET /shipping/rates
Scope: shipping:read (publishable OK).
Query params:
| Param | Notes |
|---|---|
country | ISO-2, defaults to store country |
currency | optional |
lines | variant_id:qty,variant_id:qty — if present, runs a real quote and returns quote.shipping_options (fully priced w/ free-shipping discounts applied) |
weight_grams, subtotal | used only when lines is absent — raw rate matching without discounts, in store's base currency/units |
Returns array of shipping options (id, name, carrier_code, price, original_price, currency?, is_pickup, delivery_days_min, delivery_days_max, zone).
GET /shipping/zones
Scope: shipping:read. Returns all zones with nested rates (secret keys see inactive rates too; publishable keys only see active:true).
POST /shipping/zones
Scope: shipping:write, secret required. Body: { name: string(1-100), countries: string(len2)[] (min 1) } (countries upper-cased). Response 201.
POST /shipping/zones/:id/rates
Scope: shipping:write, secret required. Body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–100 |
carrier_code | string|null | no | max 40 |
rate_type | enum | no | flat|weight|price |
price | int | yes | ≥0 |
min_weight/max_weight | int|null | no | grams, for weight rates |
min_subtotal/max_subtotal | int|null | no | minor units, for price rates |
free_over | int|null | no | makes rate free above this subtotal |
is_pickup | bool | no | |
delivery_days_min/max | int|null | no | |
active | bool | no |
Response 201 with rate row.
GET /pickup-locations
Scope: shipping:read. Returns active pickup locations, ordered by name.
GET /carriers
Scope: shipping:read. Returns { code, name, tracking_url_template, active }[].
Taxes
GET /taxes/rates
Scope: taxes:read (publishable OK). Optional country filter (ISO-2). Returns [{ country_code, tax_class, rate, name }] plus meta: { prices_include_tax, store_country, destination_based }.
PUT /taxes/rates
Scope: taxes:write, secret required. Body: { country_code: string(len2), tax_class: standard|reduced|super_reduced|zero, rate: number(0-100), name?: string(max40, default "VAT") }. Upserts on (store_id, country_code, tax_class).
POST /taxes/calculate
Scope: taxes:read (publishable OK). Body: { amount: int, tax_class?: enum (default standard), country?: string(len2), buyer_vat_id?: string|null(max30), prices_include_tax?: bool (default store setting) }. Applies the same reverse-charge/export-zero resolution as checkout. Returns { net, tax, gross, rate, tax_country, reverse_charge, export_zero_rated }.
Discounts
GET /discounts
Scope: discounts:read, secret required. Paginated, newest first.
GET /discounts/:id
Scope: discounts:read, secret required.
POST /discounts
Scope: discounts:write, secret required.
Body:
| Field | Type | Required | Constraints |
|---|---|---|---|
title | string | yes | 1–200 |
code | string|null | no | max 60, uppercased; unique per store (409 on conflict) |
is_automatic | bool | no | applies without a code if true |
discount_type | enum | yes | percentage|fixed|free_shipping|bundle|volume |
value | number | no | ≥0 (percent or minor-unit amount depending on type) |
min_subtotal | int|null | no | ≥0, minor units (base currency) |
min_quantity | int|null | no | ≥0 |
applies_to | enum | no | all|products|collections |
product_ids | uuid[] | no | |
collection_ids | uuid[] | no | |
bundle_product_ids | uuid[] | no | |
volume_tiers | array | no | [{min_qty:int≥1, percent:0-100}] |
usage_limit | int|null | no | ≥1, total uses allowed |
usage_per_customer | int|null | no | ≥1 |
starts_at/ends_at | ISO datetime|null | no | |
active | bool | no | |
combinable | bool | no | stacks with other combinable discounts |
Response 201.
PATCH /discounts/:id
Scope: discounts:write, secret required. Same schema, partial.
DELETE /discounts/:id
Scope: discounts:write, secret required.
POST /discounts/validate
Scope: checkout (publishable OK). Body: { code: string(1-60), lines: [{variant_id,quantity}], currency?, email?: string|null, shipping_country?: string(len2)|null }. Runs a quote with check_stock:false and reports: { valid: bool, message: string|null, discount: AppliedDiscount|null, discount_total: int, free_shipping: bool }.
Analytics
POST /events
Scope: analytics:write (publishable OK — this is meant for storefront tracking pixels).
Accepts either a single event object or { "events": [ ... up to 50 ] }. Event schema:
| Field | Type | Required | Notes |
|---|---|---|---|
event_type | enum | yes | page_view|product_view|add_to_cart|remove_from_cart|checkout_started|purchase|search|custom |
session_id | string | yes | max 100 |
path | string|null | no | max 500 |
referrer | string|null | no | max 500; auto-derives source hostname if source not given |
source,medium,campaign | string|null | no | max 100 each |
value | int|null | no | minor units |
currency | enum|null | no | |
order_id | uuid|null | no | |
props | record<string, unknown> | no | arbitrary extra JSON |
Response 202: { "accepted": <count> }.
GET /analytics/summary
Scope: analytics:read, secret required. Query: from, to (ISO dates, default last 30 days).
Response:
{
"data": {
"from": "2026-01-01T00:00:00.000Z",
"to": "2026-01-31T00:00:00.000Z",
"orders": 120,
"paid_orders": 104,
"revenue_by_currency": { "PLN": 2450000, "EUR": 81000 },
"report": {
"base_currency": "PLN",
"totals": { "orders": 120, "paid_orders": 104, "revenue": 2794000, "net_sales": 2271544, "cogs": 1100000, "tax": 522456, "shipping": 180000, "discounts": 95000, "refunds": 12000, "aov": 26865, "awaiting_payment": 9, "awaiting_confirmation": 3, "unfulfilled": 14, "cancelled": 7, "cancelled_value": 160000, "new_customers": 61, "returning_orders": 43 },
"series": [{ "date": "2026-01-01", "revenue": 89000, "orders": 4, "profit": 31000 }],
"best_sellers": [{ "product_id": "…", "title": "Ceramic Mug", "quantity": 52, "revenue": 155948, "profit": 61000 }],
"discounts": [{ "code": "SPRING10", "uses": 18, "discount_amount": 42000, "revenue": 380000 }],
"returns": { "count": 2, "refunded": 12000 },
"status_breakdown": { "open": 12, "processing": 30, "completed": 71, "cancelled": 7 },
"payment_breakdown": { "unpaid": 9, "awaiting_confirmation": 3, "paid": 104, "refunded": 1 },
"countries": [{ "country": "PL", "orders": 98, "revenue": 2300000 }],
"abandoned_carts": { "count": 40, "with_email": 22, "value": 410000, "converted": 61 },
"traffic": [{ "source": "google", "sessions": 820, "purchases": 31 }],
"funnel": { "sessions": 4100, "product_views": 2300, "add_to_cart": 610, "checkout_started": 210, "purchases": 104 }
}
}
}revenue_by_currency sums paid orders in their own currencies. Everything inside report is converted to the store base currency using the store exchange rates. Values are illustrative; field names are exact.
report field | Meaning |
|---|---|
totals.revenue | Paid orders total minus refunds |
totals.net_sales | Item revenue without tax |
totals.cogs | Cost of goods sold (variant cost snapshot × quantity) |
totals.aov | Average paid order value |
totals.returning_orders | Paid orders from customers who ordered before |
series[] | One entry per day: revenue, orders, profit |
best_sellers[] | Top 10 products by quantity |
discounts[] | Top 20 codes by uses |
countries[] | Top 15 shipping countries by revenue |
traffic[] | Top 15 sources by sessions (from POST /events) |
funnel | Distinct sessions per funnel step |
GET /abandoned-carts
Scope: orders:read, secret required. Carts with status in (open,abandoned), non-empty items, not updated within store.abandoned_cart_minutes. Paginated.
Building a storefront checkout — step by step
All requests below use a publishable key (os_pk_...) unless noted, sent as Authorization: Bearer os_pk_....
1. Get store info (currency, bank details, etc.)
curl https://your-openshopy-domain/api/public/v1/store \
-H "Authorization: Bearer os_pk_live_xxx"2. Browse products
curl "https://your-openshopy-domain/api/public/v1/products?status=active&limit=20" \
-H "Authorization: Bearer os_pk_live_xxx"3. Create a cart
curl -X POST https://your-openshopy-domain/api/public/v1/carts \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "items": [{ "variant_id": "a1b2c3d4-...", "quantity": 2 }] }'Response includes "token": "ck_..." — save it.
4. Set email + shipping address (triggers live quote recompute)
curl -X PATCH https://your-openshopy-domain/api/public/v1/carts/ck_... \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"email": "buyer@example.com",
"shipping_address": { "line1": "Main St 1", "city": "Warsaw", "postal_code": "00-001", "country_code": "PL" }
}'5. List shipping options for that address and pick one
curl "https://your-openshopy-domain/api/public/v1/shipping/rates?country=PL&lines=a1b2c3d4-...:2" \
-H "Authorization: Bearer os_pk_live_xxx"curl -X PATCH https://your-openshopy-domain/api/public/v1/carts/ck_... \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "shipping_rate_id": "r1..." }'6. (Optional) Apply a discount code
curl -X POST https://your-openshopy-domain/api/public/v1/discounts/validate \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "code": "SPRING10", "lines": [{ "variant_id": "a1b2c3d4-...", "quantity": 2 }] }'curl -X PATCH https://your-openshopy-domain/api/public/v1/carts/ck_... \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "discount_code": "SPRING10" }'7. Review the final quote
curl https://your-openshopy-domain/api/public/v1/carts/ck_... \
-H "Authorization: Bearer os_pk_live_xxx"Check data.quote.issues is empty before proceeding.
8. Checkout → creates the order
curl -X POST https://your-openshopy-domain/api/public/v1/carts/ck_.../checkout \
-H "Authorization: Bearer os_pk_live_xxx" \
-H "Content-Type: application/json" \
-d '{}'Response is the order. Important fields:
{
"data": {
"id": "o1...",
"name": "#1042",
"status": "open",
"payment_status": "unpaid",
"total": 8398,
"currency": "PLN",
"payment": {
"method": "bank_transfer",
"reference": "ORDER 1042",
"due_at": "2024-06-08T00:00:00+00:00",
"customer_marked_paid_at": null,
"paid_at": null,
"page_url": "https://your-openshopy-domain/order/9f1c2a7e-...-token"
}
}
}9. Redirect the customer to the payment page
data.payment.page_url (built from the order's payment_token) is https://your-openshopy-domain/order/<payment_token> — send the shopper there to see bank transfer instructions (IBAN etc. come from GET /store's payment.bank_transfer).
10. Customer confirms they sent the transfer
This is normally done by the frontend page at /order/<payment_token>, calling:
curl -X POST https://your-openshopy-domain/api/public/v1/payments/9f1c2a7e-...-token/mark-sent \
-H "Authorization: Bearer os_pk_live_xxx"→ payment_status becomes awaiting_confirmation.
11. Merchant confirms receipt (secret key, back office)
curl -X POST https://your-openshopy-domain/api/public/v1/orders/o1.../confirm-payment \
-H "Authorization: Bearer os_sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "notify": true }'→ payment_status: paid, order auto-advances to processing, confirmation email is sent, invoice is auto-issued if the store has that enabled.