REST API
The KahunaCart admin talks to the store over HTTP, so every capability it has is an endpoint you can call yourself. This page covers authentication, the conventions every route follows, and the permissions they check. Each resource group has its own page of endpoints.
Note
The storefront JSON API is separate and not listed here. It lives under the storefront route, is public, and has no MCP tools. See Storefront JSON API.
The Grav API plugin serves the endpoints and owns authentication, the response envelope, error formatting, CORS and rate limiting. KahunaCart registers its routes into that router and enforces its own permissions on top.
Before you begin
- Grav 2.0 or later, with the API plugin 1.0 or later installed and enabled.
- KahunaCart installed and enabled on the same site.
- An API key, JWT token or admin session for a user holding the
kahunacart.*permissions your routes require. See Permissions. - The base URL. In a stock install it is your site plus
/api/v1, such ashttps://example.com/api/v1. The prefix comes from the API plugin'srouteandversion_prefixconfig keys, so a store that changed either has a different base.
Every path here is relative to that base. GET /kahunacart/products means GET https://example.com/api/v1/kahunacart/products.
Authentication
The API plugin accepts these credentials, and each carries the authenticated user's permissions.
| Credential | How to send it | Best for |
|---|---|---|
| API key | X-API-Key: grav_... |
CI, server-to-server, scripts |
| JWT token | X-API-Token: <token> |
Browser and mobile clients |
| JWT bearer token | Authorization: Bearer <token> |
HTTP clients on hosts that keep the header |
| Session cookie | The grav-site cookie |
Admin extensions running inside the admin |
Generate an API key from the command line:
bin/plugin api keys:generate --user=admin --name="Warehouse sync"
The key is shown once. --expiry=30 sets a lifetime in days; without it the key never expires. bin/plugin api keys:list and bin/plugin api keys:revoke manage the rest. You can also generate keys from a user's profile in the admin.
JWT tokens come from POST /auth/token with a username and password, last an hour by default, and renew at POST /auth/refresh. Each refresh revokes the previous refresh token.
Important
Prefer X-API-Token over Authorization: Bearer. PHP under FastCGI, CGI and PHP-FPM strips the Authorization header, so a bearer token that works on one host fails silently on another. The server accepts either transport for the same token.
Authentication runs before routing, so an unauthenticated request gets 401 whether or not the path exists.
For token rotation, key scopes, CORS origins, rate limits and environments, see the Grav API plugin documentation.
Conventions
Response envelope. Every successful response is JSON wrapped in a data object.
{ "data": { "product": { "id": 7, "slug": "reef-runner-tee" } } }
Error envelope. Errors are RFC 7807 problem documents, served as application/problem+json.
{ "status": 422, "title": "Validation failed", "detail": "Attribute 'material' has no option 'hemp'; it accepts cotton, linen, wool" }
status and title are the status code and its short name; detail is the sentence to show a person. API plugin exceptions may add a machine-readable code, and validation failures an errors array of {field, message} pairs.
Status codes.
| Code | When |
|---|---|
200 |
The request succeeded. |
201 |
A resource was created. The Location header names it. |
403 |
The credential is missing a permission, or the store is a read-only demo. |
404 |
The resource does not exist, or it belongs to a different parent. |
409 |
The request conflicts with the current state — a duplicate slug, code or version, an order in the wrong status, an attribute still in use. |
413 |
An uploaded file is over the configured cap. |
415 |
An uploaded image is not a format KahunaCart accepts. |
422 |
The body is malformed, or a value was refused. |
500 |
Storage or rendering failed. The detail names the cause. |
KahunaCart never answers 204. A delete answers 200 with {"deleted": true}, often plus a count of what else it affected.
Pagination. Paginated endpoints take ?page= and ?per_page=. page starts at 1, and per_page is capped by the API plugin's pagination.max_per_page (1000 by default). Default page size is 25 for products, orders, customers and webhook deliveries, and 100 for coupons.
Pagination sits inside data, not in the API plugin's meta block:
{ "data": { "products": [], "total": 412, "page": 2, "per_page": 25 } }
total is the whole result set, not the page. Report endpoints use the same three keys where they page. Categories, tags, attributes, tax zones, shipping zones and sales are not paginated: they answer with the whole list.
Searching and filtering. ?q= is a substring search on products (title, slug, variant SKU), orders (order number, email) and customers (email, name, Grav username; ?email= is the older name and still works). The webhook log filters on ?provider=. Report endpoints take ?from=YYYY-MM-DD&to=YYYY-MM-DD and default to the last 30 days. There is no generic sort parameter; each endpoint returns its own order.
Money. Amounts cross the wire as decimal strings in the store's base currency ("19.99"), never as floats. A record that names a currency of its own, such as an order, a price list or a currency_amounts object on a store that sells in more than one currency, is in that currency's own decimal places ("18.50" in euro, "2700" in yen). They are stored and reported in minor units as integers on any field whose name ends in _minor: price_minor, total_minor, refunded_minor. Read currency_exponent from GET /kahunacart/config to convert between the two. It is 2 for USD, 0 for JPY, 3 for KWD, and a store that sells in more than one currency also gets currencies, the exponent of each. A money field that is not a decimal amount is refused with 422 before anything is written.
Percentages. Percentages are decimal strings end to end ("8.25"), for the same reason: no discount is ever computed from a float.
See Money conventions for how a store's currency and exponent are set.
Booleans. Stored flags come back as the integers 1 and 0: enabled, filterable, show_on_product, apply_to_shipping. On the way in, true, false, 1 and 0 are all accepted. Two fields read as real JSON booleans: a release's published and is_default inside a variant plan.
Timestamps. Unix epoch seconds as integers: created_at, updated_at, published_at, starts_at, expires_at, received_at. Report ranges are YYYY-MM-DD date strings instead. A release's released_at takes an epoch or anything PHP's strtotime() reads, so 2026-08-25 and last friday both work.
Partial updates. PATCH writes only the fields the body names and leaves an absent key alone. For list-valued fields, present means replace: category_ids, tags, translations, a tax zone's rates, a shipping zone's methods and a sale's rules each replace the whole set, and an empty list removes the last entry. attributes is the exception: it merges, so only the slugs you name are touched, and a slug sent as null is removed.
Translations. translations is written the same way on every record that has one: products, categories, tags, attributes, options and their values, sales, manufacturers, and each shipping method and tax rate inside its zone. Leave it out and the stored translations are left alone. Send it and it replaces them all. A shipping method or a tax rate is the one exception: a zone is saved with its whole set of rows, so a row sent without translations has none afterwards. Send each row's map back with it, as the admin does. A map that is wrong is a 422 and nothing in the request is saved. See Multilanguage for the map and what is refused.
Creates. Every 201 carries a Location header with the new resource's path, such as /kahunacart/products/12.
Multipart routes. Uploads are multipart/form-data with the bytes in a field named file. KahunaCart derives the stored path from the product id, a sanitized basename and a random suffix; the client never names it. Downloadables are checked against an extension allowlist. Images and category pictures are checked by reading the bytes, and must be JPEG, PNG, WebP, AVIF or GIF. The multipart routes are product files, product images, category images and release publish.
Non-JSON responses. GET /kahunacart/reports/export answers text/csv; GET /kahunacart/reports/export.pdf and the two packing-slip routes answer application/pdf; the two admin script routes answer application/javascript. These send a Content-Disposition or an ETag rather than the JSON envelope. A route with no MCP tool is one of these or a multipart upload.
Demo stores. When demo.readonly is on, every KahunaCart admin route that is not GET, HEAD or OPTIONS is refused with 403 before the handler runs, add-on routes included. Reads are never blocked, and the storefront, checkout, webhooks and job worker are untouched. See Demo store.
Migrations. Every KahunaCart admin request runs autoMigrate() first, so a store with database.auto_migrate set to auto applies pending schema changes on the first API call after an upgrade.
Caching. KahunaCart's JSON routes send Cache-Control: no-store and implement neither ETag nor If-Match, so the API plugin's optimistic concurrency covers its own core resources, not these. The two admin script routes are the exception: they carry an ETag from the file's mtime and size, and answer a conditional GET with 304.
Rate limiting. The API plugin's rate limiter applies to these routes like any other. When enabled, responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Amounts in other currencies
On a store that sells in more than one currency (currencies.enabled is not empty) the product routes, the sale routes, bulk edit and the import fields carry the prices set in the other currencies. A single-currency store sees none of it: no currency_amounts key in any answer, and a request that sends one is refused with a 422. See Pricing a product in another currency.
currency_amounts is an object of currency code to the amounts that owner has there, each a decimal string in that currency's own decimal places ("18.00" in euro, "2700" in yen). It rides on the route that already writes the base amount:
| On | Fields | Written by |
|---|---|---|
| A variant | price, compare_price |
PATCH /products/{id}/variants/{variantId}, variants in PATCH /products/{id}, variant or variants in POST /products |
| An add-on price | amount (a fixed price) |
option_prices.{optionId}.{valueId} in PATCH /products/{id} |
| The product | pwyw_suggested, pwyw_max, deposit |
PATCH /products/{id} and POST /products |
| A sale rule or tier | amount (a fixed one) |
rules and tiers in POST /sales and PATCH /sales/{id} |
| A sale rule's operation | the name of each money argument it declares |
rules in POST /sales and PATCH /sales/{id} |
A fixed rung of a ladder in a rule's args.tiers |
amount |
the rung itself, rules[].args.tiers[].currency_amounts |
| A coupon | amount, min_total, max_total |
POST /coupons and PATCH /coupons/{id}. See Coupons. |
| A campaign's template | amount, min_total, max_total |
template.currency_amounts in POST /coupon-groups and PATCH /coupon-groups/{id}, stamped on each code the campaign mints |
| A payment method | fee_fixed |
POST /payment-methods and PATCH /payment-methods/{id}, beside currencies, the list of currencies it is offered in. See Payment methods. |
PATCH /kahunacart/products/12/variants/21
{ "price": "28.00", "currency_amounts": { "EUR": { "price": "25.50", "compare_price": "30.00" }, "JPY": { "price": "4200" } } }
An answer lists only the amounts that exist, so a variant sold in euro and not yen reads:
{ "id": 21, "price_minor": 2800, "currency_amounts": { "EUR": { "price": "25.50", "compare_price": "30.00" } } }
On a write, a key left out is left alone, and an empty string or null removes that amount, which means the owner cannot be bought in that currency. With typed pricing nothing is worked out from the base price, and on a store that converts a missing amount is worked out when it is read, never stored (see Exchange rates and currency pricing). A currency the store does not sell in, the base currency, a negative amount, a number with more decimal places than the currency has, or a name the owner does not take is refused with a 422 that names it, and the whole request writes nothing, base price included.
Sales rewrite their rules and tiers on every save, so their ids change. A rule or tier sent without currency_amounts keeps the amounts of the one it replaces: rules are matched by position, scope, scope id and operation, tiers by min_quantity. What a row does send wins over what was kept, and the old ids' amounts are removed in the same transaction. A percentage rule or tier has no amount of its own, so giving it currency_amounts is a 422.
A rule that prices through an operation of its own names its amounts after the operation's money arguments. An operation that declares bonus and cap as money takes {"EUR": {"bonus": "4.50", "cap": "18.00"}}, and a name it does not declare as money is a 422 that lists the ones it does. A rule that has an amount in one currency has to have each of them there, or the rule gives nothing in that currency.
A quantity ladder in a rule's arguments (the quantity_tiers operation) carries its amounts on the rung, because a rung is the thing that has an amount. Each fixed rung takes currency_amounts in the same form a sale's own tiers do, and a percentage rung is refused one:
POST /kahunacart/sales
{ "name": "Wax ladder", "label": "Buy more, save more",
"rules": [ { "scope": "product", "scope_id": 12, "operation_code": "quantity_tiers",
"args": { "basis": "units", "tiers": [
{ "min_quantity": 2, "mode": "fixed", "amount": "1.00", "currency_amounts": { "EUR": { "amount": "0.70" }, "JPY": { "amount": "100" } } },
{ "min_quantity": 4, "mode": "percent", "percent": "20" } ] } } ] }
A read gives each fixed rung its currency_amounts (an empty object when it has none) and a percentage rung none. A rule sent back without them on its rungs keeps them, the way a rule without currency_amounts does. A rung taken out of the ladder, or turned into a percentage, loses its amounts in the same save.
Price lists and a variant's quantity ladder take a currency of their own, because each already carries its currency on every row. POST /price-lists takes currency (the base currency when it is left out), the list is read and written in it from then on, and a PATCH that names a different one is a 422. GET /price-tiers takes currency on its query and PUT /price-tiers in its body, and each answer names currency on a store that sells in more than one. A currency the store does not sell in is a 422. See Pricing.
Three more places take part:
GET /products?missing_price=EURlists only the products with at least one variant that has no price in that currency. Any code the store does not sell in, including every code on a single-currency store, is a422.- Bulk edit has two more
fieldvalues on such a store,currency_priceandcurrency_fill, listed byGET /products/bulk/fieldsalong withcurrenciesandroundings.currency_pricetakescurrency,target(priceorcompare_price) and avaluein that currency (empty removes it).currency_filltakescurrency,target, aratesuch as"0.92"(a positive decimal string with up to twelve decimal places; a JSON number with a fraction is refused, because it loses digits on the way in) and aroundingofnone,whole,99or95. A preview names each changeprice_EURorcompare_price_JPY, with amounts in that currency's minor units andnullfor "no price". - Import mappings may map columns to
price_EURandcompare_price_EUR, one pair per enabled currency, as the import fields route lists them.
Exchange rates and currency pricing
On a store that sells in more than one currency, see Currencies → Exchange rates. All of these need kahunacart.settings.
| Route | MCP tool | What it does |
|---|---|---|
GET /kahunacart/currencies/rates |
list_exchange_rates |
The rate source and its fallback (with fallback_label, null when there is none), how many working days old a fetched rate may get, and one row per currency the store sells in: its rate (a decimal string, how many of the currency one unit of the base buys), where it came from, the day its source says it is for (rate_date, YYYY-MM-DD, null for a typed rate), when it was last checked (fetched_at, age_seconds), whether a person typed it (pinned), whether it counts as old (stale) or has no rate (missing), held (null, or the fetched answer held because it moved more than currencies.rates.hold_move_percent: old, new, moved_percent, source, date, at), and market on a typed rate (null, or what the last refresh got for it: rate, source, date, and away_percent, how far the typed rate is from it). last_refresh says how the last fetch went, which source answered each rate (answered_by), and in note why the source couldn't answer when the fallback did. A single-currency store answers enabled false and no rows, and no fallback. |
GET /kahunacart/currencies/rates/lookup |
look_up_exchange_rates |
Ask Frankfurter once for today's market rates for the base and every currency the store sells in, using currencies.rates.frankfurter_url and frankfurter_providers, and write nothing. Answers currency => { "rate", "date", "source" }, and for a currency with a rate you typed also "away_percent", how far that rate is from the market one as a decimal string ("5.05"). The server works that out each time, and it isn't kept with the cached answer. The answer is kept for an hour per base and currencies, so asking again within the hour makes no request. 502 with the reason when Frankfurter can't answer, and 404 on a store that sells in one currency. |
PUT /kahunacart/currencies/rates/{quote} |
set_exchange_rate |
Type the rate for one currency, as {"rate": "0.92"}. A typed rate is pinned, and a refresh leaves it alone. A rate sent as a JSON number with a fraction is refused, because a rate is never a float. Answers what the read answers. |
DELETE /kahunacart/currencies/rates/{quote} |
delete_exchange_rate |
Remove a rate, typed or fetched. The next refresh fills it in again from the source. |
POST /kahunacart/currencies/rates/{quote}/accept |
accept_exchange_rate |
Accept a held rate, sending the held rate you saw as {"new": "58.4"}. It is written with the source and day it came with, and not pinned, so it keeps updating. onKahunaCartExchangeRatesRefreshed fires with that one change. 409 when the hold is gone or now holds a different rate, or when the rate in use was typed or changed since the hold was made, and nothing is written. Answers what the read answers. |
DELETE /kahunacart/currencies/rates/{quote}/held |
keep_old_exchange_rate |
Keep the old rate and let the hold go. The next refresh holds the rate again if the move is still there. Typing a rate is how to stop being asked. Answers what the read answers. |
POST /kahunacart/currencies/rates/refresh |
refresh_exchange_rates |
Fetch the rates now: the source first, then the fallback for whatever it didn't answer. Unlike the daily job this runs inside the request, because a person pressing the button is waiting for the answer. Answers result (with answered_by, fallback, note, fallback_error and the held answers) beside what the read answers, and 422 when there is nothing to fetch (the manual source). |
PUT /kahunacart/currencies/pricing |
set_currency_pricing |
Save the per-currency pricing choices and the store-wide missing-price choice, answer the rates document and queue a catalog index update. |
On a converting store, GET /kahunacart/currencies/rates also answers pricing: missing_price, and currencies, a map keyed by code with the configured mode, the effective converts, markup_percent as a decimal string and rounding. The other rate writes return it too. A single-currency store has no pricing key.
Save with {"pricing":{"EUR":{"mode":"converted","markup_percent":"2.5","rounding":"99"}},"missing_price":"unavailable"}. The base currency and disabled currencies are refused. Each currency you send needs all three fields, and a currency you leave out is left alone. mode is manual or converted. markup_percent is a string from -90 to 1000 with at most four decimal places, and a JSON float is refused. rounding is none, whole, 99, 95 or nearest_005. missing_price is optional (unavailable or convert), and leaving it out keeps it. convert overrides every enabled currency's manual mode when it has a rate, and keeps its markup and rounding. Typed amounts, zero included, always win. A currency with no rate is allowed in the config and gives no converted amount, rather than a guessed price. See Converted prices.
On a converting store, product and variant reads carry converted_amounts, using the same named decimal fields as currency_amounts. They are worked out from the saved base price at the current rate, even when a typed override exists. They are hints for an editor, not typed prices, and not a shopper's own sale or list price. They are absent on a store where every currency is typed. A client must send only typed overrides in currency_amounts.
Reports in more than one currency
The report summary, products, customers, taxes, refunds, comparison and recovery routes, and the customer list and detail routes, accept currency=all as well as a known currency code. The default stays the base currency. all adds exactly, using each order's own fx_to_base, rounded once in the base currency. An order with no recorded rate is left out, never given today's rate or a guessed 1. A converted answer names the base currency, sets currency_selection: all and converted: true, and includes excluded_orders and excluded_currencies ({currency, orders} rows). Reports also carry excluded_refunds and excluded_refund_currencies ({currency, refunds}), and recovery carries excluded_pending. A comparison's metadata describes the selected range, and its comparison has the same coverage metadata for the comparison range. A single-currency answer gets none of these fields. See Reports and totals.
| Method | Route | MCP tool | Permission |
|---|---|---|---|
GET |
/kahunacart/reports/currency |
get_report_currency |
Any of kahunacart.reports, kahunacart.customers.view, kahunacart.orders.view |
PUT |
/kahunacart/reports/currency |
set_report_currency |
Any of the same permissions |
GET /kahunacart/reports/currency returns currencies (the base first, then the currencies with completed orders, sorted by code), base_currency and the calling admin's saved currency. A single-currency store returns only currencies: []. PUT /kahunacart/reports/currency takes { "currency": "all" } or a known code, remembers it for this admin, and answers the same as the GET. A bad code, or a value that isn't a string, is a 422. The manifest lists these two tools under kahunacart.reports, and the HTTP route also takes customer and order readers. A query parameter always wins, and the API never applies a saved choice when a call names no currency.
currency_picker=1 on the financial report routes or the customer list asks for report_currency_available: true when several currencies are enabled or foreign orders have been recorded. It adds nothing on a single-currency store and runs no extra query. The admin uses it to find historical currencies even in an empty date range.
GET /kahunacart/reports/summary?currency_table=1 also returns by_currency, with one row per currency: currency, orders, revenue_minor, aov_minor, base_currency, base_revenue_minor (null when no order has a rate), converted_orders and excluded_orders. The revenue and the average are in the row's own currency, and the base revenue covers only the rated orders. Each row rounds on its own.
A converted refund row carries a base amount_minor (null with no rate), original_amount_minor, original_currency and a nullable fx_to_base. A customer answer carries total_spent_base, base_currency, converted_orders and the coverage counts. A total filter in the converted view compares exact base amounts with a threshold in base minor units. A saved report view accepts and returns range.currency, either a code or all.
On a store with several currencies, the report ledger CSV adds fx_to_base beside currency. The order and line item exports always include both columns, even with custom columns, in CSV and JSON, and list_export_column_sets lists fx_to_base in its vocabulary. They also always include settlement_currency, settlement_amount and settlement_rate, what the payment provider paid out for the order's first charge that has a settlement, and list_export_column_sets offers those three on such a store only. A missing rate is an empty string. Ledger amounts stay in their original currencies: the converted choice changes the report totals and the PDF summaries, not ledger rows. A single-currency store's columns and bytes are unchanged: it leaves fx_to_base and the three settlement columns out of an export and of a saved set even when a request names them, with no error. If that leaves no column at all, the export uses the default columns for its scope, as if none were named.
Orders in more than one currency
On a store that sells in more than one currency (currencies.enabled is not empty) the order routes carry the order's currency. A single-currency store gets none of the new keys and a request that sends currency is answered as before, and an order's amounts are always minor units of its own currency, so two orders on the same list can be in different units. Never add the totals of orders without checking their currencies match. See Orders in another currency.
| Route | What is new |
|---|---|
GET /orders |
currency=EUR narrows the list, its total and its search to that currency. The answer gains currencies, every currency the store sells in or has orders in (the base first, then the enabled ones, then any other that has orders), each { "code", "count" }, and currency, the one the list is narrowed to or null. Any three-letter code is taken, so an order in a currency the store no longer sells in can still be found, and anything else is a 422. A single-currency store ignores the parameter and answers neither key |
POST /orders |
currency names the order's currency: the store's own or one it sells in, the store's own when left out. Every product is priced in it. A code the store does not sell in, a code that is not a string, or a product with no price in the currency is a 422 and creates nothing |
GET /orders/{id} |
On an order in a currency other than the base, cost_currency names the base currency, because a line's cogs_minor is always in it. An order in the base currency has no such key. A transaction the payment provider converted carries settlement (currency, amount_minor, rate, reference, recorded_at), what it paid out: see What the payment provider paid out |
POST /orders/{id}/changes/{cid}/actions |
An add_item with a variant_id is priced from the variant's amount in the order's currency when no unit_price_minor is sent, and a variant with no price in the currency is a 422 whatever price is sent |
POST /orders/{id}/refund, .../refunds/record, POST /credit/refunds |
amount is a decimal in the order's own currency ("12.50" in euro, "1250" in yen). The provider is asked in that currency, and a refund to credit lands in the customer's account in it |
| An orders import | A row keeps its currency, and its amounts are read with that currency's decimals. A currency the store does not sell in fails the row |
curl -s 'https://example.com/api/v1/kahunacart/orders?currency=EUR&per_page=2'
{ "orders": [{ "id": 412, "currency": "EUR", "total_minor": 1700 }], "total": 1, "currency": "EUR", "currencies": [{ "code": "USD", "count": 3 }, { "code": "EUR", "count": 1 }, { "code": "JPY", "count": 1 }] }
{ "email": "[email protected]", "currency": "JPY", "items": [{ "variant_id": 21, "qty": 2 }] }
Permissions
Every route checks one permission. A super admin passes every check. Everyone else also needs the API plugin's own api.access permission before a KahunaCart permission is consulted.
| Permission | Plugin | Label | Unlocks | Routes | Tools |
|---|---|---|---|---|---|
kahunacart.orders.view |
KahunaCart | View Orders | Admin sections, Agentic, Alerts, Config, Counter, Credit, Custom fields, Features, Notices, Order downloads, Orders, Payment collections, Recovery, Settings store, Shipping, Store status, Zones | 46 | 39 |
kahunacart.orders.manage |
KahunaCart | Manage Orders | Counter, Counter carts, Countries, Features, Notices, Order downloads, Orders, Payment collections, Reader payments, Recovery, Transactions | 63 | 60 |
kahunacart.orders.refund |
KahunaCart | Issue Refunds | Counter, Credit, Orders | 14 | 14 |
kahunacart.customers.view |
KahunaCart | View Customers | Customers, Payment tokens, Stock alerts, Store transfer | 8 | 7 |
kahunacart.customers.manage |
KahunaCart | Manage Customer Accounts | Customers, Payment tokens, Stock alerts | 16 | 16 |
kahunacart.products.manage |
KahunaCart | Manage Products | Attributes, Bulk, Catalog index, Checkout fields, Countries, Import, Manufacturers, Pickup locations, Product files, Product labels, Product media, Product options, Product revisions, Product types, Products, Relations, Releases, Reviews, Stock alerts, Store transfer | 98 | 90 |
kahunacart.sales.manage |
KahunaCart | Manage Sales | Price lists, Rules, Sales | 14 | 14 |
kahunacart.settings |
KahunaCart | Manage KahunaCart Settings | Agentic, Alerts, Categories, Config transfer, Coupon groups, Coupons, Currency pricing, Currency rates, Custom fields, Customer groups, Hooks, Jobs, Notification channels, Notifications, Object templates, Ops, Payment methods, Providers, Reader payments, Report views, Schema extensions, Shipping, Store status, Tags, Tax, Webhook log, Webhook subscriptions | 123 | 111 |
kahunacart.reports |
KahunaCart | View KahunaCart Reports | Admin sections, Report views, Reports | 19 | 16 |
kahunacart.export |
KahunaCart | Export the Store | Store transfer | 6 | 5 |
kahunacart.restore |
KahunaCart | Restore the Store from a Bundle | Store transfer | 7 | 6 |
kahunacart.licenses |
Licenses | Manage Licenses | License products, License settings, Licenses | 18 | 18 |
kahunacart.subscriptions.view |
Subscriptions | View Subscriptions | Plans, Subscriptions | 6 | 6 |
kahunacart.subscriptions.manage |
Subscriptions | Manage Subscriptions | Plans, Subscriptions | 11 | 11 |
kahunacart.newsletters.view |
Newsletters | View Newsletters | Campaigns, Customer imports, Customers, Deliverability, Flows, Imports, Lists, Overview, Providers, Reports, Segments, Subscribers, Suppressions, Templates | 33 | 30 |
kahunacart.newsletters.manage |
Newsletters | Manage Newsletters | Campaigns, Customer imports, Deliverability, Flows, Imports, Lists, Providers, Segments, Subscribers, Suppressions, Templates | 45 | 42 |
kahunacart.newsletters.send |
Newsletters | Send Newsletter Campaigns | Campaigns | 5 | 5 |
kahunacart.shipping.manage |
Shipping | Manage Carriers and Boxes | Accounts, Addresses, Aggregators, Boxes, Customs, Locations | 19 | 19 |
kahunacart.shipping.labels |
Shipping | Buy and Void Shipping Labels | Labels | 4 | 4 |
kahunacart.bookings.view |
Bookings | View Bookings and Setup | Bookings, Other | 17 | 17 |
kahunacart.bookings.manage |
Bookings | Manage Bookings and Setup | Bookings, Other, Sessions | 35 | 35 |
kahunacart.bookings.desk |
Bookings | Work the whole diary from the front desk | — | 0 | 0 |
kahunacart.bookings.desk.own |
Bookings | Work your own diary from the front desk | — | 0 | 0 |
The Licenses add-on's public endpoints need no permission and no account; the licence key is the credential. See Public license routes. The Shipping add-on's tracking webhook is public too, with a secret in its path as the credential. See Parcel tracking.
Note
An API key created with a non-empty scope list is capped to those scopes regardless of what its owning account may do. A scoped key minted on a super-admin account is still capped.
Endpoints
Every endpoint has an entry on one of the pages below, generated from the plugins themselves, so each page lists every route its group registers including those no MCP tool covers. An entry carries the method, the path, the permission it checks, what it does, and its parameters with types and defaults.
The MCP tool for a route is the same name with the plugin prefix in front: list_products is kahunacart_list_products at an assistant. See Run the store from an AI assistant.
KahunaCart
- Store (7 endpoints). The store-level reads every other call is interpreted against, and the admin scripts the shell fetches.
- Products (10 endpoints). The catalog's core resource: products, their variants and the fields both carry.
- Product options (9 endpoints). Option axes such as Size or Colour, their values, and the variant matrix they generate.
- Product files (4 endpoints). The downloadable files attached to a product, and the metadata on each.
- Product media (7 endpoints). Pictures, video, embeds and samples attached to a product.
- Product labels (4 endpoints). A product's EU energy labels and label images, such as a warranty badge.
- Product releases (7 endpoints). Versioned releases of a product and the builds hung off them.
- Categories (8 endpoints). The catalog tree, returned flat with a parent id on every row.
- Tags (4 endpoints). The flat half of the catalog taxonomy.
- Manufacturers (4 endpoints). Who makes what you sell: the legal name, address and EU responsible person shown in the Product safety section, and the default safety text.
- Attributes (6 endpoints). The store-wide vocabulary a product's specification table is written in.
- Tax (13 endpoints). Tax zones, the rates hanging off them, the rate CSV, price preferences and the tax engines a zone can be bound to.
- Shipping (4 endpoints). Shipping zones and the methods they offer.
- Shipping rates and parcels (10 endpoints). What a shipping method charges, the profiles that split an order into parcels, and the drivers a live carrier plugs into.
- Pickup locations (4 endpoints). Named collection points a customer can choose at checkout instead of delivery.
- Coupons (4 endpoints). Discount codes a shopper types in at checkout.
- Coupon campaigns (7 endpoints). The header a batch of generated coupon codes hangs off.
- Rules and conditions (2 endpoints). Read-only: the vocabulary sale conditions are written against, and the operations rules price through.
- Catalog index, types and relations (9 endpoints). The denormalised index behind listings and facets, the synonym table, product types and related products.
- Price lists, bulk edits and imports (21 endpoints). Price lists and quantity tiers, the bulk product editor, and the CSV import pipeline.
- Store export and restore (16 endpoints). Exporting the whole store into one bundle, restoring a bundle into another store, and the history of both.
- Order statuses (5 endpoints). The values each of an order's three status axes may hold.
- Order changes (9 endpoints). Amendments to a placed order, proposed and confirmed as a unit, with a version per confirmed change.
- Features (2 endpoints). The core storage features a store switches on, and what refuses one.
- Store credit (7 endpoints). The append-only credit ledger, its transactions, and the credit notes an adjustment writes.
- Gift cards (4 endpoints). Gift cards, their redemption into store credit, and the void.
- Customer groups (5 endpoints). The groups a pricing rule, a coupon condition, a shipping rule or a member-only product points at.
- Wishlists (1 endpoint). Read-only: one customer's saved lists and what is on them.
- Stock alerts (3 endpoints). The back-in-stock alerts shoppers have asked for, on a product or held by a customer, and stopping one.
- Erasure and reassignment (4 endpoints). Erasing a customer's personal data, and moving their orders to another account first.
- Retention (2 endpoints). The store's data retention policy, and the run that applies it.
- Reviews (5 endpoints). The moderation queue for customer reviews, and the rating rebuild.
- Payment methods (5 endpoints). The merchandised checkout choices over each registered provider.
- Bulk orders and exports (8 endpoints). Status changes across many orders at once, the order export, and the saved column sets.
- Quotes (3 endpoints). Turning a draft order into a priced quote, and the accept and decline that close it.
- Custom fields, settings and zones (25 endpoints). The extension platform: custom fields, the add-on settings store, admin zones, schema extensions, hook points, product revisions and part payment.
- Outbound webhooks (7 endpoints). The endpoints this store posts events to, and the delivery log for them.
- Jobs (3 endpoints). The background job queue: what is pending, what failed, and running or cancelling one by hand.
- Health, drift and previews (5 endpoints). The store health check, the template drift report, and the admin's document previews.
- Notifications (8 endpoints). The registry of merchant notifications, and switching one on or off.
- Object templates (3 endpoints). The small Twig templates that print an order, a product or a customer inside a notification.
- Sales (5 endpoints). Automatic discounts with no code involved: a header, its rules and its conditions.
- Reports (8 endpoints). Read-only sales, product, customer, tax, refund and stock reports, plus the CSV and PDF exports.
- Orders (52 endpoints). The commercial record: reading an order, moving it through its statuses, refunding it, and its notes, emails and downloads.
- Customers (12 endpoints). The customer record, read-only, their address book, the country list address forms draw on, and the three actions on their sign-in account.
- Webhook log (2 endpoints). Read-only delivery history for the webhooks payment providers post to this store.
- Cart recovery (5 endpoints). Abandoned carts, the recovery email and its restore link, share links, the email log and the recovery rate.
- Payment providers (15 endpoints). The registered payment providers, and the operator actions against one.
- Checkout fields and country address rules (7 endpoints). The checkout form as editable rows, and the per-country address rules the form and the documents follow.
- Analytics and saved views (9 endpoints). Comparison reports, the vocabulary a filter is written against, saved views, and which columns a list screen shows.
- Dashboard alerts (3 endpoints). The strip at the top of the store admin that says what needs attention, and the two actions on it.
- Configuration transfer and contradictions (4 endpoints). Moving a store's configuration as a document, and finding the settings that disagree with each other.
- Agentic commerce (6 endpoints). The readiness status, signing key, feed files and agent sessions behind the Agentic commerce screen.
- Exchange rates and currencies (12 endpoints). The exchange rates between your currencies, converted-price settings, the currency a report is shown in, and the gateway account behind a payment method.
Licenses
- Licenses (add-on) (9 endpoints). Issuing, editing, suspending, revoking and regenerating license keys, and freeing a seat.
- Licensing rules (add-on) (6 endpoints). Which products issue a key, and on what terms.
- Licensing settings (add-on) (3 endpoints). Store-wide licensing settings, including the response-signing secret.
- Public license routes (add-on) (7 endpoints). Unauthenticated routes that answer software running on a customer machine, with the key as the credential.
Subscriptions
- Subscription plans (add-on) (7 endpoints). The plans that turn one variant into something sold on a calendar.
- Subscriptions (add-on) (10 endpoints). One customer's billing record, and the actions that pause, extend, cancel or restart it.
Newsletters
- Newsletter campaigns (add-on) (20 endpoints). Writing, previewing, test-sending, starting, pausing and stopping a campaign, and what each one did.
- Newsletter subscribers (add-on) (12 endpoints). The people on the mailing list, their consent and memberships, and the link to a KahunaCart customer.
- Newsletter lists and tags (add-on) (6 endpoints). The topics people subscribe to and leave separately, and the tags that mark them.
- Newsletter segments (add-on) (8 endpoints). Saved conditions that pick people out of a list, the vocabulary they are written in, and their counts.
- Newsletter templates (add-on) (7 endpoints). The layouts a campaign is written into, and their previews.
- Newsletter automations (add-on) (12 endpoints). Automations and their steps, the ready-made recipes, and the people walking through each one.
- Newsletter imports (add-on) (6 endpoints). Bringing people in from a CSV file or from the store's own customers, and the history of each run.
- Newsletter delivery (add-on) (9 endpoints). The suppression list, the deliverability checks, and the email provider webhooks.
- Newsletter overview and reports (add-on) (5 endpoints). The front screen in one call, the settings as the store sees them, and the Reports tab.
Shipping
- Shipping carriers (add-on) (8 endpoints). The carrier aggregator, its connection check and tracking webhook, and the carrier accounts behind it.
- Boxes and packing (add-on) (9 endpoints). The box catalogue, the ship-from addresses, and what the packer would do with an order.
- Shipping labels (add-on) (6 endpoints). Quoting an order, buying a label, voiding one, and the label file itself.
- Parcel tracking (add-on) (4 endpoints). A parcel's tracking history, asking the carrier again, and the webhook the aggregator posts to.
- Addresses and customs (add-on) (3 endpoints). The carrier's address check, and the customs facts recorded against a product.
Bookings
- Bookings (add-on) (11 endpoints). The diary: listing, booking, approving, cancelling, completing and moving a booking, and the CSV export.
- Bookings overview and settings (add-on) (4 endpoints). The overview calendar in one call, Troubleshoot, the add-on's settings, and the scripts behind screens other plugins add.
- Bookable people and places (add-on) (17 endpoints). The people, rooms and equipment whose time can be taken, their working hours, their time off and the locations they work at.
- Booking services (add-on) (9 endpoints). What a product means as a booking: how long, how often, how far ahead, and what it needs.
- Booking calendar sync (add-on) (7 endpoints). Calendar links for phones and for channels such as Airbnb, the calendar files behind them, and testing an outside calendar.
- Classes and waiting lists (add-on) (8 endpoints). Classes and workshops, their dates and registers, and the waiting list behind each date.
- Storefront booking routes (add-on) (11 endpoints). The unauthenticated routes a booking page and a customer's own booking link call.
- Front desk routes (add-on) (11 endpoints). The narrowed routes the front desk app at /desk calls for somebody standing at the counter.
Rentals
- Rentals (add-on) (9 endpoints). The properties let by the night, their stay rules, the rate card, and the quote for a stay.
Print on demand
- Print on demand providers (add-on) (6 endpoints). The providers a store can connect, their connection check and card switches, webhooks, and the shipping methods they add.
- Print on demand import (add-on) (4 endpoints). The products designed at a provider, the import sheet, importing one as a draft, and refreshing it.
- Print on demand custom products (add-on) (10 endpoints). A provider's catalogue of blanks, Made by, the links on a product, and its print files.
- Print on demand bulk links (add-on) (5 endpoints). Assign by SKU, the links CSV in and out, and the bulk runs with their undo.
- Print on demand orders (add-on) (8 endpoints). The provider orders behind a sale, the review queue, and sending, checking and cancelling at the provider.
Point of sale
- Point of sale registers and devices (add-on) (7 endpoints). The registers a till sells from, their card readers, and the browsers paired to them.
- Point of sale staff (add-on) (4 endpoints). Who may sell at a till, their PINs, and unlocking somebody after wrong tries.
- Point of sale favourites (add-on) (5 endpoints). The product buttons on the Sell screen, and the product search that finds what to put on them.
- Point of sale shifts and reports (add-on) (4 endpoints). Shifts with their X and Z reports, the till sale behind an order, and the sales report.
Examples
Every example uses https://example.com/api/v1 as the base and an API key. Swap in -H "X-API-Token: $TOKEN" for a JWT.
List products matching a search.
curl -s "https://example.com/api/v1/kahunacart/products?q=tee&page=1&per_page=25" \
-H "X-API-Key: grav_abc123..."
Create a product with two variants and attribute values.
curl -s -X POST https://example.com/api/v1/kahunacart/products \
-H "X-API-Key: grav_abc123..." \
-H "Content-Type: application/json" \
-d '{
"title": "Reef Runner Tee",
"slug": "reef-runner-tee",
"status": "published",
"type": "physical",
"tax_class": "standard",
"category_ids": [3],
"tags": ["organic", "new"],
"attributes": { "material": "organic-cotton", "fabric-weight": "185" },
"variants": [
{ "sku": "RRT-S", "title": "Small", "price": "29.00", "stock_managed": 1, "stock_qty": 20, "is_default": true },
{ "sku": "RRT-M", "title": "Medium", "price": "29.00", "compare_price": "35.00", "stock_managed": 1, "stock_qty": 14 }
]
}'
Answers 201 with {"data": {"product": {...}}} and a Location of /kahunacart/products/{id}.
Upload a product image.
curl -s -X POST https://example.com/api/v1/kahunacart/products/12/images \
-H "X-API-Key: grav_abc123..." \
-F [email protected] \
-F alt="Reef Runner Tee, front"
Publish a release from CI.
curl -s -X POST https://example.com/api/v1/kahunacart/products/12/releases/publish \
-H "X-API-Key: $KAHUNACART_API_KEY" \
-F file=@dist/widget-2.4.1-macos.zip \
-F version=2.4.1 \
-F released_at=2026-08-25 \
-F "changelog=<CHANGELOG-2.4.1.md"
Run it once per build artifact. Repeating the version adds a file to the same release.
Mark an order paid.
curl -s -X POST https://example.com/api/v1/kahunacart/orders/482/mark-paid \
-H "X-API-Key: grav_abc123..."
Answers {"data": {"payment_status": "paid"}}, or 409 when the payment was not pending.
Refund part of an order.
curl -s -X POST https://example.com/api/v1/kahunacart/orders/482/refund \
-H "X-API-Key: grav_abc123..." \
-H "Content-Type: application/json" \
-d '{"amount": "19.99"}'
Omit the body to refund everything not already refunded.
Export a month of orders as CSV.
curl -s "https://example.com/api/v1/kahunacart/reports/export?from=2026-07-01&to=2026-07-31" \
-H "X-API-Key: grav_abc123..." \
-o kahunacart-july.csv
Related
- KahunaCart Provider Contract
- Writing a KahunaCart Payment Provider
- Admin sections
- Run the store from an AI assistant
- Products and catalog
- Orders and the admin
- Money conventions
- Merchant notifications
- Events
- Twig functions and filters
- Signed links and rate limits
- Custom fields and the settings store
- Extension zones
- Object templates
- Outbound webhooks
- Storefront JSON API
- Newsletters
- Grav API plugin