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 therouteconfig key. Default/shop, or/de/shopon a multilanguage site. - It is on by default. Set
storefront_api.enabledtofalseto answer every/apiURL 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:
{
"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.
{
"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/itemstakescurrencyin 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 /carttakes?currency=EUR. For a visitor with no cart it says which currency the empty cart will start in, and nothing is created.PATCH /carttakescurrencyto 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:
{
"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
curl -s https://example.com/shop/api/cart
{
"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:
"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.
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:
{
"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.
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
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:
partsisn't an object:422.- A pick is missing:
422withPick 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:
422naming the part. - A pick isn't a whole number or a string of digits, so
2.9or"02e0":422withThat is not one of the choices for "Easel". - A kit item id isn't a part the shopper picks in this product:
422withThat 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.
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
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
{
"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
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:
{ "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.
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.
{
"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.
Related
- Storefront
- Configuration → Storefront JSON API
- Money conventions
- Hardening
- Writing a KahunaCart Payment Provider
- Storefront MCP, the public endpoint for AI agents, which builds carts of its own and never touches the session cart this API serves
- Currencies