Skip to content

Powered by Grav CMS + Helios Theme

Storefront JSON API

A JSON API over the cart, shipping quotes, payment methods, checkout and the country list, for a JavaScript or headless storefront. This page is for developers building a custom front end against a KahunaCart store.

The API is available alongside the existing HTML pages, forms and fragments.

Before you begin

  • The API answers under the storefront base route, {base}/api, where {base} is the route config key. Default /shop, or /de/shop on a multilanguage site.
  • It is on by default. Set storefront_api.enabled to false to answer every /api URL with a 404. See Configuration → Storefront JSON API.
  • Every endpoint is public: a shopper is not an authenticated user. The cart belongs to the session.

The endpoints

Method Path Answers
GET {base}/api/cart The session's cart, empty or not
PATCH {base}/api/cart The cart after setting the estimate address, the shipping method or the currency
DELETE {base}/api/cart 204, the cart emptied
POST {base}/api/cart/items 201 and the cart, a line added
PATCH {base}/api/cart/items/{id} The cart, a quantity changed. 0 removes the line
DELETE {base}/api/cart/items/{id} The cart, a line removed. 422 for a free gift or a part of a locked set
PATCH {base}/api/cart/sets/{key} The cart, a number of whole sets of a grouped product. 0 removes the set
DELETE {base}/api/cart/sets/{key} The cart, every line of that set removed
POST {base}/api/cart/coupons The cart, a code applied
DELETE {base}/api/cart/coupons/{code} The cart, a code taken off
POST {base}/api/cart/bundles The cart, a bundle's parts swapped for the bundle
DELETE {base}/api/cart/bundles/{variant_id} The cart, that bundle no longer offered
GET {base}/api/shipping The zone and the methods on offer, or 422
GET {base}/api/payment-methods The gateways and merchandised methods this cart may pay with
POST {base}/api/checkout The order placed: complete, redirect, or 422
GET {base}/api/countries Every country, and which have regions
GET {base}/api/countries/{code}/regions The states or provinces of one

Status codes

Code When
200 A read, or a change that answers the new state
201 A line created
204 The cart emptied. No body
404 A resource, line, coupon or country that is not there
405 A verb the resource does not take. The Allow header lists the ones it does
415 A write that did not arrive as application/json
422 Everything a shopper could fix: validation, an empty cart, no shipping address, a refused payment
429 Rate limited. Retry-After says how long in seconds
503 The store is coming soon or paused and isn't taking orders (store_closed). Retry-After is an hour

A shipping quote with no address is a 422, not an empty list. PATCH /cart with an address first, then ask again.

The error format

Every error is one object:

JSON
{
  "error": {
    "status": 422,
    "code": "validation",
    "title": "Validation failed",
    "detail": "The line could not be added.",
    "fields": { "qty": "must be a whole number of at least 1" }
  }
}

code is a stable machine-readable identifier. detail is the message to display. fields is present only when the problem concerns specific inputs.

detail and fields are always plain text. A refusal can carry a link, such as "Sign in to the account that holds your license", and when it does, the same sentence also comes as detail_html, and a field's as fields_html. Both are already escaped with only their links and bold drawn, so they are safe to put in a page as they are. Both are absent when there is nothing to draw, so a client that shows text needs no change. See Messages with a link in them.

JSON
{
  "error": {
    "status": 422,
    "code": "validation",
    "title": "Validation failed",
    "detail": "Video only is for stores that already have Photos. Sign in to the account that holds your Studio license, or choose Photos + Video.",
    "detail_html": "Video only is for stores that already have Photos. <a href=\"/shop/account?return=%2Fproduct%2Fvideo\">Sign in</a> to the account that holds your Studio license, or choose Photos + Video."
  }
}
code Status Meaning
validation 422 An input is wrong. Read fields
empty_cart 422 There is no cart, or it has no lines
shipping_address_required 422 Ask for quotes after setting a country
payment_failed 422 The provider refused. detail is the provider's reason
bundle_not_offered 422 POST /cart/bundles named a bundle that is not on offer for this cart
not_found 404 No such resource, line, coupon or country
method_not_allowed 405 See the Allow header
unsupported_media_type 415 Send Content-Type: application/json
rate_limited 429 See Retry-After
store_closed 503 The store is coming soon or paused, so cart writes and checkout are refused for everybody but signed-in staff. detail is the store's message and store_status says which. Retry-After is an hour. Reading the cart and cancelling an open payment still work. See Configuration → Store status

Writes are JSON, and that is the CSRF story

A POST, PATCH or DELETE must arrive as Content-Type: application/json, or it is a 415. A cross-site form cannot send that content type without a CORS preflight the browser refuses, so a JSON body came from a script on this origin or from a non-browser client. That is the CSRF protection.

The HTML forms keep their nonces. The API has no nonce to fetch and no token to pass. See Signed links and rate limits.

Caching

Every customer-specific answer carries Cache-Control: no-store and Vary: Cookie. The country and region lists are the same for everybody and carry Cache-Control: public, max-age=86400.

Nothing writes on a GET. A cart read is served from the rows as they are; totals are recalculated only on writes, as with the forms.

Rate limits

The API shares the buckets the HTML forms use, counted per client address: 60 cart writes a minute and 10 checkout submits. A limited request answers 429 with Retry-After. Behind a CDN you must fill in security.trusted_proxies or every visitor shares one bucket. See Hardening → Rate limits.

Money in a payload

Every amount appears twice: *_minor is the integer in minor units, and *_formatted is the string rendered in the cart's currency and the locale. Nothing is ever a float.

The same code as the Twig kahunacart_price filter renders both, so a JSON client and the HTML fragment print the same number. See Money conventions.

Currencies

A store that sells in more than one currency takes a currency on three calls, and every other call answers in the currency the cart was made in.

  • POST /cart/items takes currency in the body. For a visitor with no cart yet it is the currency the new cart starts in. Without it the cart starts in the visitor's own currency: the one they picked, or the one their saved address or the country header gives, or the store's base currency.
  • GET /cart takes ?currency=EUR. For a visitor with no cart it says which currency the empty cart will start in, and nothing is created.
  • PATCH /cart takes currency to move an open cart to another currency. See below.

The code is three letters in any case and has to be one the store sells in: the base currency or one under currencies.enabled. Anything else is a 422 with the codes the store does sell in. A store with one currency takes its own code and refuses every other.

A cart is in the currency it was made in. Naming another currency on POST /cart/items or GET /cart for a cart that exists is a 422 with code: "cart_currency" and a sentence that says how to move it, and nothing is added:

JSON
{
  "error": {
    "status": 422,
    "code": "cart_currency",
    "title": "The cart is in EUR",
    "detail": "This cart is in EUR and a cart stays in the currency it was made in. Send PATCH /cart with {\"currency\": \"USD\"} to move it to USD, or empty it first.",
    "fields": { "currency": "the cart is in EUR" }
  }
}

A product with no price in the cart's currency is refused when it is added, the way any other line a store will not sell is: a 422 with the sentence "Reef Tee is not available in EUR.".

Moving a cart. PATCH /cart with {"currency": "EUR"} moves an open cart to that currency. Every line is priced again there, a line with no price in the new currency is removed, a code or gift card that cannot be used in it is dropped, and a shipping method or payment method that is not offered in it is cleared so checkout asks for it again. The answer is the cart in its new currency with one more key, currency_change, that says what happened, so a client can tell the shopper. It is {switched, refusal, from, to, repriced, removed, dropped_codes, shipping_reset, payment_reset}: how many lines were priced again, the lines taken out (each with its title, a reason and a detail), the codes dropped, and whether the shipping and payment choices were cleared. It is on that one answer only. Naming the currency the cart is already in changes nothing and adds no key. A cart that cannot be moved (every line would be removed, or it is no longer a cart) is a 422 with the reason in fields.currency.

With currencies.lock_to_country on, the mapped shipping country decides for delivery. The billing country decides for downloads or collection. Updating the address can move the cart and return currency_change. If checkout moves it, it returns 422 currency_changed and does not place the order. Read GET /cart, show the new totals, then retry checkout. An unmapped country or the current currency leaves it alone. A cart that has ever opened a hosted payment page cannot move, even after local cancellation; the HTML checkout offers a new cart.

What carries a currency in a payload. The cart, every line, every adjustment and every total are in the cart's currency, and the cart says which in currency, as it always has. On a store with more than one currency, GET /shipping also names the currency its rates are in, as a top-level currency key. A store with one currency adds no key to any answer.

Read the cart

BASH
curl -s https://example.com/shop/api/cart
JSON
{
  "id": 412,
  "currency": "USD",
  "empty": false,
  "item_count": 3,
  "items": [
    {
      "id": 981, "product_id": 12, "variant_id": 34, "sku": "TSHIRT-M",
      "title": "T-shirt (M)", "qty": 2,
      "unit_price_minor": 1999, "unit_price_formatted": "$19.99",
      "total_minor": 3998, "total_formatted": "$39.98",
      "is_digital": false, "line_data": {}
    }
  ],
  "adjustments": [],
  "summary_adjustments": [],
  "totals": {
    "items_minor": 3998, "items_formatted": "$39.98",
    "adjustments_minor": 320, "adjustments_formatted": "$3.20",
    "total_minor": 4318, "total_formatted": "$43.18"
  },
  "coupons": [],
  "address": { "country": "US", "region": "CA", "postcode": "94103" },
  "shipping_method_id": null,
  "language": null,
  "bundle_suggestions": []
}

adjustments is every stored adjustment row. summary_adjustments collapses each coupon and sale into one line, for a totals table.

Sets in the cart

Adding a grouped product or a kit puts its parts in the cart as separate lines. Each line it added carries a set object, and it's null on a line added on its own:

JSON
"set": {
  "product_id": 54, "key": "54-311", "title": "Starter easel set",
  "per_set": 1, "expansion": "children", "locked": false,
  "picks": {"812": 311}, "picked": ["Walnut"]
}
Key What it holds
product_id, title The grouped product, and its name as the shopper read it
key The set's identity. It's the product id for a set with no picked parts ("54"), and the product id followed by the picked variant ids for one that has them ("54-311"), so a Walnut easel set and a Birch easel set are two sets
per_set How many of this line one set holds
expansion children for a grouped product, kit for a kit
locked True when the parts change only through the set
picks Kit item id to the variant the shopper picked. Empty for a set with no picked parts
picked The picked variants' names

To draw a set as one row, group the lines by set.key.

BASH
curl -s -X PATCH https://example.com/shop/api/cart/sets/54-311 \
  -H 'Content-Type: application/json' -d '{"sets": 2}'
curl -s -X DELETE https://example.com/shop/api/cart/sets/54-311

The PATCH says how many whole sets the cart should hold. Every part moves by per_set for each set added or taken away, and a spare part is left where it is. 0 takes the set out. The DELETE takes every line of the set out. Both answer with the cart. A bare product id, which is what a client written before sets could be picked sends, still finds a set of that product when the cart holds only one. When it holds two, a bare id is a 404.

On a locked set, a PATCH or DELETE on one line on its own is a 422 with a sentence naming the set, so draw a locked set's parts with no controls of their own. See Kits and grouped products → In the cart.

Bundle suggestions

With cart.bundle_suggestions on, bundle_suggestions on the cart lists every bundle this basket holds all the parts of and would pay less for. It is always present, and empty otherwise. Each entry:

JSON
{
  "bundle_variant_id": 22, "bundle_product_id": 21,
  "bundle_title": "Video — Photos + Video", "bundle_name": "Photos + Video", "sets": 1,
  "parts": [
    {"item_id": 981, "variant_id": 20, "title": "Photos", "name": "Photos", "qty": 1},
    {"item_id": 982, "variant_id": 21, "title": "Video — Video only", "name": "Video only", "qty": 1}
  ],
  "separate_minor": 29800, "bundle_minor": 24900, "saving_minor": 4900,
  "currency": "USD", "signature": "22:1:981,982",
  "message": "Photos and Video only come to $298.00 here. Photos + Video is $249.00 and includes both."
}

title and bundle_title are the full cart line titles. name and bundle_name are the short names the sentence uses: a product that sells one variant by its title, a variant of a product that sells several by its own name.

BASH
curl -s -X POST https://example.com/shop/api/cart/bundles \
  -H 'Content-Type: application/json' -d '{"bundle_variant_id": 22}'
curl -s -X DELETE https://example.com/shop/api/cart/bundles/22

The POST swaps and answers the cart. A bundle that is no longer on offer for this cart is a 422 with the code bundle_not_offered, and a refusal from the add (stock, a quantity rule, a plugin's veto) is a 422 with its sentence. The DELETE is "Keep them separate": the offer leaves bundle_suggestions until the basket forms a new match, and a bundle that is not on offer is a 404. See Bundle suggestions for when an offer is made.

Add a line and change it

BASH
curl -s -X POST https://example.com/shop/api/cart/items \
  -H 'Content-Type: application/json' \
  -d '{"variant_id": 34, "qty": 2}'

variant_id is required. qty defaults to 1. line_data is an optional object for add-on line data, the same one onKahunaCartCartLineKey and onKahunaCartCartLineValidate see.

For a pay-what-you-want product, send the shopper's amount as a decimal in line_data.pay_amount, for example {"variant_id": 51, "line_data": {"pay_amount": "15.00"}}. Leave it out and the line is priced at the product's suggested price. An amount below the minimum or above the maximum is a 422 whose sentence names the product and the limit.

For a grouped product or a kit with a part the shopper picks, send parts: an object of kit item id to variant id, such as {"variant_id": 54, "parts": {"812": 311}}. The kit item ids are the id of each entry in the product's kit_items, which you read from the products API. Nothing is added when:

  • parts isn't an object: 422.
  • A pick is missing: 422 with Pick which "Easel" you want.
  • A pick is a variant of another product, a sold-out one, or one the quantity can't be bought at: 422 naming the part.
  • A pick isn't a whole number or a string of digits, so 2.9 or "02e0": 422 with That is not one of the choices for "Easel".
  • A kit item id isn't a part the shopper picks in this product: 422 with That choice is not part of "Starter easel set".

See Which variant a part sells.

The answer is 201 with the whole cart, so you never need a second request to redraw.

BASH
curl -s -X PATCH https://example.com/shop/api/cart/items/981 \
  -H 'Content-Type: application/json' -d '{"qty": 5}'

qty: 0 removes the line. A quantity the stock cannot cover is a 422 carrying the same sentence the form shows, and so is a quantity outside the product's quantity rules: below its minimum, above its maximum, or off its step. The message names the product and the number. A member-only product the caller may not buy is refused the same way.

Set the address and get quotes

BASH
curl -s -X PATCH https://example.com/shop/api/cart \
  -H 'Content-Type: application/json' \
  -d '{"address": {"country": "US", "region": "CA", "postcode": "94103"}}'

curl -s https://example.com/shop/api/shipping
JSON
{
  "required": true,
  "address_known": true,
  "zone": { "id": 2, "name": "United States" },
  "methods": [
    { "id": 5, "label": "Standard", "rate_minor": 500, "rate_formatted": "$5.00", "selected": false }
  ],
  "selected": null
}

required: false means this cart needs no shipping: every line is digital, or the store has no shipping zones. Choose a method with PATCH /cart and {"shipping_method_id": 5}; null clears the choice. A method id not on offer for this cart and address is a 422.

Asking for quotes before setting an address is a 422 with code: "shipping_address_required".

Place the order

BASH
curl -s -X POST https://example.com/shop/api/checkout \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "[email protected]",
    "billing": {"name": "A Buyer", "line1": "1 Market St", "city": "San Francisco", "region": "CA", "postcode": "94103", "country": "US"},
    "shipping_same": true,
    "payment_provider": "stripe"
  }'

email and billing are required. Send shipping_same: false and a shipping object to ship somewhere else; a shipping object with shipping_same omitted means the same thing. payment_provider and payment_method come from GET /payment-methods.

There are two successful responses:

JSON
{ "status": "complete", "order_id": 412, "receipt_url": "https://example.com/shop/receipt/…", "transaction_ref": "pi_…" }
{ "status": "redirect", "order_id": 412, "redirect_url": "https://checkout.stripe.com/…", "transaction_ref": "cs_…" }

Send the shopper to redirect_url; the provider returns them to {base}/payment/return/{transactionHash}, as with the form. A refused payment is a 422 with code: "payment_failed".

Checkout re-checks the whole basket before any money moves, so a cart built before a rule was set is refused here even though every line was accepted when it was added. A basket outside the store's order rules, a line outside its product's quantity rules, and a line whose product the caller may no longer buy are all a 422 with code: "validation", carrying the same sentence the cart page prints.

Important

Never post card details to a KahunaCart route. A provider either redirects the shopper to its hosted page or collects the card in its own iframe on your page. A request carrying anything that looks like a card number in any field is answered 422 card_data_refused before anything reads it. The refusal names the field, not the value.

The API does not run the address-suggestion step the HTML form can run. Validate the address on your own side before posting. See Address validation.

Add your own keys to a response

onKahunaCartExtendStorefrontSchema fires once per response. An add-on can add keys to a documented payload without forking the controller.

PHP
public static function getSubscribedEvents(): array
{
    return ['onKahunaCartExtendStorefrontSchema' => ['onKahunaCartExtendStorefrontSchema', 0]];
}

public function onKahunaCartExtendStorefrontSchema(Event $event): void
{
    if ($event['resource'] !== 'line_item') {
        return;
    }

    $event['payload']['warranty_months'] = $this->months($event['context']['item']);
}
Key on the event What it is
resource cart, line_item, shipping, payment_methods, checkout or countries
payload An ArrayObject to write your keys onto
context The rows the payload was built from: the order, the item, the shipping options, the result

An add-on cannot overwrite a documented base-plugin key; any attempted replacement is dropped and logged. Add-ons can add new keys.

A listener that throws is dropped and the documented payload goes out as it was. See Events.

The extensions object

onKahunaCartExtendStorefrontSchema writes bare keys onto a payload, which breaks when two add-ons want the same key. The registry solves that: an add-on registers a namespace with a declared schema, and everything it returns lands inside one reserved top-level key.

JSON
{
  "totals": { "total": "48.00" },
  "items": [],
  "extensions": {
    "kahunacart-licenses": { "seats": 5, "expires_at": 1780000000 },
    "kahunacart-subscriptions": { "renews_at": 1780000000 }
  }
}

Two add-ons can both call a key status without clashing. No documented key moves: extensions is appended beside them, and it is absent on a store where no add-on registered one.

Rule What happens
A namespace two plugins claim Refused with a message naming both plugins. The second registration never takes effect
A key the callback returns but did not declare Dropped, with a log line naming the namespace, the key and the point
A value whose type does not match the declared one Dropped, with a log line saying what was returned and what was declared
A callback that throws That namespace is missing from the response. Every other namespace still goes out

The storefront points are storefront.cart, storefront.line_item, storefront.shipping, storefront.payment_methods, storefront.checkout and storefront.countries. See Schema extensions for how to register one, and GET /kahunacart/schema-extensions for what an installed store has registered.