Skip to content

Powered by Grav CMS + Helios Theme

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 as https://example.com/api/v1. The prefix comes from the API plugin's route and version_prefix config 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:

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

JSON
{ "data": { "product": { "id": 7, "slug": "reef-runner-tee" } } }

Error envelope. Errors are RFC 7807 problem documents, served as application/problem+json.

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:

JSON
{ "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.
JSON
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:

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

JSON
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=EUR lists 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 a 422.
  • Bulk edit has two more field values on such a store, currency_price and currency_fill, listed by GET /products/bulk/fields along with currencies and roundings. currency_price takes currency, target (price or compare_price) and a value in that currency (empty removes it). currency_fill takes currency, target, a rate such 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 a rounding of none, whole, 99 or 95. A preview names each change price_EUR or compare_price_JPY, with amounts in that currency's minor units and null for "no price".
  • Import mappings may map columns to price_EUR and compare_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
BASH
curl -s 'https://example.com/api/v1/kahunacart/orders?currency=EUR&per_page=2'
JSON
{ "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 }] }
JSON
{ "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

Subscriptions

Newsletters

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

Point of sale

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.

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

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

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

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

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

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

BASH
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