OpenShopy
Dokumentacja API

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):

FieldTypeConstraints
currencyenumPLN|USD|EUR, defaults to store default
languageenumpl|en, defaults to store default
itemsarray[{ variant_id: uuid, quantity: int(1-10000) }], max 200
emailstring|nullemail
discount_codestring|nullmax 60
shipping_addressaddress|nullsee the address schema in Orders → POST /orders
billing_addressaddress|null
shipping_rate_iduuid|null
pickup_location_iduuid|null
buyer_vat_idstring|nullmax 30
notestring|nullmax 2000
utmrecord<string,string>|null

Response 201 — cart object with live quote:

json
{
  "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.email must be set → else email_required
  • Either pickup_location_id set, or shipping_address has line1, city, postal_code, country_code → else address_required
  • Either shipping_rate_id or pickup_location_id set → else shipping_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:

json
{
  "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:true discounts 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 every bundle_product_ids present in cart, percent off those lines), volume (percent off once volume_tiers quantity threshold reached, highest qualifying tier wins).
  • applies_to: all | products (matches product_ids) | collections (matches collection_ids).
  • Tax country resolution: if store.tax_destination_based and destination is in the EU, tax country = destination; else store's own country.
  • Reverse charge applies when: store has reverse_charge_enabled, a buyer_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_class and inclusive/exclusive math per store.prices_include_tax.

Shipping

GET /shipping/rates

Scope: shipping:read (publishable OK).

Query params:

ParamNotes
countryISO-2, defaults to store country
currencyoptional
linesvariant_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, subtotalused 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:

FieldTypeRequiredNotes
namestringyes1–100
carrier_codestring|nullnomax 40
rate_typeenumnoflat|weight|price
priceintyes≥0
min_weight/max_weightint|nullnograms, for weight rates
min_subtotal/max_subtotalint|nullnominor units, for price rates
free_overint|nullnomakes rate free above this subtotal
is_pickupboolno
delivery_days_min/maxint|nullno
activeboolno

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:

FieldTypeRequiredConstraints
titlestringyes1–200
codestring|nullnomax 60, uppercased; unique per store (409 on conflict)
is_automaticboolnoapplies without a code if true
discount_typeenumyespercentage|fixed|free_shipping|bundle|volume
valuenumberno≥0 (percent or minor-unit amount depending on type)
min_subtotalint|nullno≥0, minor units (base currency)
min_quantityint|nullno≥0
applies_toenumnoall|products|collections
product_idsuuid[]no
collection_idsuuid[]no
bundle_product_idsuuid[]no
volume_tiersarrayno[{min_qty:int≥1, percent:0-100}]
usage_limitint|nullno≥1, total uses allowed
usage_per_customerint|nullno≥1
starts_at/ends_atISO datetime|nullno
activeboolno
combinableboolnostacks 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:

FieldTypeRequiredNotes
event_typeenumyespage_view|product_view|add_to_cart|remove_from_cart|checkout_started|purchase|search|custom
session_idstringyesmax 100
pathstring|nullnomax 500
referrerstring|nullnomax 500; auto-derives source hostname if source not given
source,medium,campaignstring|nullnomax 100 each
valueint|nullnominor units
currencyenum|nullno
order_iduuid|nullno
propsrecord<string, unknown>noarbitrary extra JSON

Response 202: { "accepted": <count> }.

GET /analytics/summary

Scope: analytics:read, secret required. Query: from, to (ISO dates, default last 30 days).

Response:

json
{
  "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 fieldMeaning
totals.revenuePaid orders total minus refunds
totals.net_salesItem revenue without tax
totals.cogsCost of goods sold (variant cost snapshot × quantity)
totals.aovAverage paid order value
totals.returning_ordersPaid 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)
funnelDistinct 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.)

bash
curl https://your-openshopy-domain/api/public/v1/store \
  -H "Authorization: Bearer os_pk_live_xxx"

2. Browse products

bash
curl "https://your-openshopy-domain/api/public/v1/products?status=active&limit=20" \
  -H "Authorization: Bearer os_pk_live_xxx"

3. Create a cart

bash
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)

bash
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

bash
curl "https://your-openshopy-domain/api/public/v1/shipping/rates?country=PL&lines=a1b2c3d4-...:2" \
  -H "Authorization: Bearer os_pk_live_xxx"
bash
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

bash
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 }] }'
bash
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

bash
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

bash
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:

json
{
  "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:

bash
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)

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