REST API
KahunaCart's admin talks to the store entirely over HTTP, so every capability the admin has is an endpoint you can call yourself. This page is the route reference for developers integrating KahunaCart with another system: an ERP or accounting sync, a CI pipeline that publishes builds, a custom admin tool, a headless storefront, or a mobile client.
The endpoints are served by the Grav API plugin, which owns authentication, the response envelope, error formatting, CORS and rate limiting. KahunaCart registers its own 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 belonging to a user who holds the
kahunacart.*permissions the routes you call require. See Permissions. - The base URL. In a stock install it is your site plus
/api/v1— for examplehttps://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 on this page is written relative to that base. GET /kahunacart/products means GET https://example.com/api/v1/kahunacart/products.
Authentication
The API plugin accepts three credentials, and all three carry 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 gives it a lifetime in days; without it the key does not expire. bin/plugin api keys:list and bin/plugin api keys:revoke manage the rest. Keys can also be generated 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 are renewed 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 before it reaches the application, so a bearer token that works on one host silently fails on another. The server accepts either transport for the same token.
Authentication runs before routing. An unauthenticated request gets 401 whether or not the path exists, so a 401 is not evidence that you spelled the route correctly.
Full details — token rotation, key scopes, CORS origins, rate limits, environments — are in the Grav API plugin documentation.
Conventions
Response envelope. Every successful KahunaCart 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. Exceptions raised by the API plugin may add a machine-readable code, and validation failures may add 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}, and often 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). Each endpoint has its own default page size: 25 for products, orders, customers and webhook deliveries, 100 for coupons.
KahunaCart puts pagination inside data rather than in the API plugin's meta block, so read it there:
{ "data": { "products": [], "total": 412, "page": 2, "per_page": 25 } }
total is the whole result set, not the page. Report endpoints follow the same three keys where they page at all. 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 for the same parameter 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 on KahunaCart routes; each endpoint returns its own order.
Money. Amounts cross the wire as decimal strings in the store currency ("19.99"), and are stored and reported in minor units as integers (price_minor, total_minor, refunded_minor). Read currency_exponent from GET /kahunacart/config to convert between them — it is 2 for USD, 0 for JPY, 3 for KWD. Percentages are decimal strings too ("8.25"), never floats. A money field that is not a decimal amount is refused with 422 before anything is written. See Money conventions.
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 exceptions 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 accepts 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. A key that is absent is left alone. For list-valued fields, present means replace: sending category_ids, tags, translations, a tax zone's rates, a shipping zone's methods or a sale's rules replaces that whole set, and sending an empty list is how the last entry is removed. attributes is the one exception — it merges, so only the slugs named are touched and a slug sent as null is removed.
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. The client never names the stored path: it is derived from the product id, a sanitized basename and a random suffix. 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 the release publish route.
Non-JSON responses. GET /kahunacart/reports/export answers text/csv, GET /kahunacart/reports/export.pdf answers application/pdf, and the two admin script routes answer application/javascript. All four send a Content-Disposition or an ETag rather than the JSON envelope.
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 — including the routes the add-ons register. Reads are never blocked, and the storefront, checkout, webhooks and the job worker are untouched.
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 do not implement ETag or If-Match; the API plugin's optimistic-concurrency support applies to its own core resources, not to these. The two admin script routes are the exception: they carry an ETag built 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 it is enabled, responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Permissions
Every route checks one permission. A super admin passes every check; anyone else also needs the API plugin's own api.access permission before a KahunaCart permission is consulted.
| Permission | Unlocks |
|---|---|
kahunacart.orders.view |
Config, the sidebar badge, orders list and detail, download grants, admin section list and section scripts |
kahunacart.orders.manage |
Create an order by hand, mark paid, fulfill, cancel, resend the confirmation, revoke a download grant |
kahunacart.orders.refund |
Refund an order |
kahunacart.customers.view |
Customer list and detail |
kahunacart.customers.manage |
Invite, clear two-factor, enable or disable a customer account |
kahunacart.products.manage |
Products, variants, option axes and values, product files, releases, product images, attribute definitions |
kahunacart.sales.manage |
Sales |
kahunacart.settings |
Categories, tags, tax, shipping, coupons, providers, the webhook log |
kahunacart.reports |
All report endpoints, the CSV and PDF exports, low stock, the dashboard widget script |
kahunacart.licenses |
Everything in the Licenses add-on's admin API (one permission, because a key is the credential itself) |
kahunacart.subscriptions.view |
Reading subscriptions, plans and metrics |
kahunacart.subscriptions.manage |
Cancel, uncancel, pause, resume, extend, note, resend a pay link, and every plan write |
The Licenses add-on's seven public endpoints require no permission and no account at all — the licence key is the credential.
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
Config
One read the admin does before anything else. Bodies are documented in depth on Orders and the admin.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/config |
kahunacart.orders.view |
Store-level facts the client needs before loading data |
The response carries currency, currency_exponent, the storefront route, the site's languages and default_language, the plugin version, providers keyed by slug with a label and a capabilities list, jobs (pending, failed, oldest_pending_age, stale, stale_after) and provider_coverage (sells_physical, has_physical_provider, digital_only_providers).
Products
The catalog's core resource. Field lists, variant rules and the is_default contract are on Products and catalog.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/products |
kahunacart.products.manage |
List products. Takes q, page, per_page |
POST |
/kahunacart/products |
kahunacart.products.manage |
Create a product with one or more variants |
GET |
/kahunacart/products/{id} |
kahunacart.products.manage |
One product with its whole gallery and recent orders |
PATCH |
/kahunacart/products/{id} |
kahunacart.products.manage |
Update fields, variants, categories, tags, attributes, translations |
DELETE |
/kahunacart/products/{id} |
kahunacart.products.manage |
Delete the product, its variants, files, releases and images |
DELETE |
/kahunacart/products/{id}/variants/{variantId} |
kahunacart.products.manage |
Remove one variant. Refuses the last one with a 422 |
Settable product fields are slug, type, status, visibility, title, summary, description, tax_class and shipping_class. title is required on create, and a duplicate slug is a 409. Variant fields are sku, title, stock_managed, stock_qty, backorders, weight_grams, position, is_default, and the decimal money fields price and compare_price.
The listing carries one image per product plus an image_count; the single read carries the whole gallery.
Product options
Option axes and the variant matrix they generate. The frozen option object and the plan payload are on Products and catalog.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/products/{id}/options |
kahunacart.products.manage |
The axes, the variants, max_options and the widget list |
POST |
/kahunacart/products/{id}/options |
kahunacart.products.manage |
Add an axis. {name, widget?, values?} |
POST |
/kahunacart/products/{id}/options/generate |
kahunacart.products.manage |
Plan the matrix. {apply: true} writes it |
POST |
/kahunacart/products/{id}/options/assign/{variantId} |
kahunacart.products.manage |
Point a variant at a combination. {option_value_ids: []} |
PATCH |
/kahunacart/products/{id}/options/{optionId} |
kahunacart.products.manage |
Rename an axis, change its widget or position |
DELETE |
/kahunacart/products/{id}/options/{optionId} |
kahunacart.products.manage |
Remove an axis. The variants stay |
POST |
/kahunacart/products/{id}/options/{optionId}/values |
kahunacart.products.manage |
Add a value. {label, color?} |
PATCH |
/kahunacart/products/{id}/options/{optionId}/values/{valueId} |
kahunacart.products.manage |
Edit a value |
DELETE |
/kahunacart/products/{id}/options/{optionId}/values/{valueId} |
kahunacart.products.manage |
Remove a value. Variants that named it become orphans |
generate never deletes. It creates the combinations that have no variant and reports the ones the axes no longer describe under plan.orphans, for you to remove through the variant delete route. Assigning a combination another variant already holds is a 409.
Product files
Downloadables attached to a product. What a grant is and how delivery works are on Digital products and downloads.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/products/{id}/files |
kahunacart.products.manage |
Every file on the product |
POST |
/kahunacart/products/{id}/files |
kahunacart.products.manage |
Upload a file. Multipart, bytes as file |
PATCH |
/kahunacart/products/{id}/files/{fileId} |
kahunacart.products.manage |
Edit name, download_limit, expiry_days, position |
DELETE |
/kahunacart/products/{id}/files/{fileId} |
kahunacart.products.manage |
Detach the file and delete the bytes |
The upload accepts name, variant_id, download_limit, expiry_days and position alongside the bytes. A file over downloads.max_upload_mb is a 413, and an extension outside the allowlist is a 422. The bytes are immutable — replacing a file means uploading a new one — and path and release_id are not editable through PATCH.
Product releases
Versioned releases and the builds hung off them. The CI flow is walked through on Product releases.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/products/{id}/releases |
kahunacart.products.manage |
Every release with its files, plus the product's evergreen files |
POST |
/kahunacart/products/{id}/releases |
kahunacart.products.manage |
Create a release. A duplicate version is a 409 |
POST |
/kahunacart/products/{id}/releases/publish |
kahunacart.products.manage |
One-shot CI call: bytes plus a version, multipart |
PATCH |
/kahunacart/products/{id}/releases/{releaseId} |
kahunacart.products.manage |
Edit version, released_at, changelog, published |
DELETE |
/kahunacart/products/{id}/releases/{releaseId} |
kahunacart.products.manage |
Delete the release and its files' rows |
POST |
/kahunacart/products/{id}/releases/{releaseId}/files/{fileId} |
kahunacart.products.manage |
Move an uploaded file into this release |
DELETE |
/kahunacart/products/{id}/releases/{releaseId}/files/{fileId} |
kahunacart.products.manage |
Take the file back out, making it evergreen |
Repeating a version on publish is not an error: the release is found rather than created and the file is added to it, so a matrix build posting three platform builds at one version produces one release with three files. published defaults to true on publish, and a repeat call never unpublishes a version that is already live.
Product images
Pictures attached to a product. The image object and its url field are on Products and catalog.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/products/{id}/images |
kahunacart.products.manage |
The gallery, in position order |
POST |
/kahunacart/products/{id}/images |
kahunacart.products.manage |
Upload a picture. Multipart: file, optional alt |
PATCH |
/kahunacart/products/{id}/images/{imageId} |
kahunacart.products.manage |
Edit alt and position |
DELETE |
/kahunacart/products/{id}/images/{imageId} |
kahunacart.products.manage |
Detach the image and delete the bytes |
The format is decided by reading the bytes, not the extension. Anything that is not JPEG, PNG, WebP, AVIF or GIF is a 415, and a file over images.max_upload_mb (8 by default) is a 413.
Categories
The catalog tree, returned flat with a parent_id on every row. See Products and catalog.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/categories |
kahunacart.settings |
The whole tree with counts, images and translations |
POST |
/kahunacart/categories |
kahunacart.settings |
Create a category |
PATCH |
/kahunacart/categories/{id} |
kahunacart.settings |
Update a category |
DELETE |
/kahunacart/categories/{id} |
kahunacart.settings |
Delete it; its children move up to its parent |
POST |
/kahunacart/categories/{id}/move |
kahunacart.settings |
{parent_id, position}. A cycle is a 422 |
POST |
/kahunacart/categories/{id}/reorder |
kahunacart.settings |
Swap with a sibling: {"direction": "up"} or "down". Answers moved: false at the end of the row |
POST |
/kahunacart/categories/{id}/image |
kahunacart.settings |
Upload the category picture. Multipart: file, optional alt |
DELETE |
/kahunacart/categories/{id}/image |
kahunacart.settings |
Remove the picture |
Settable fields are title, slug, description, parent_id, position, display and image_alt. image_path is server-derived and cannot be set. Every row carries product_count (filed directly here) and subtree_count (what a customer finds when they click it). One picture per category, so the upload replaces rather than appends.
Tags
The flat half of the taxonomy. Tags are assigned to products through the product body's tags list, not from here.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/tags |
kahunacart.settings |
Every tag with product_count and translations |
POST |
/kahunacart/tags |
kahunacart.settings |
Create a tag. {label, slug?} |
PATCH |
/kahunacart/tags/{id} |
kahunacart.settings |
Edit label or slug |
DELETE |
/kahunacart/tags/{id} |
kahunacart.settings |
Delete the tag and every assignment. Answers products_affected |
Attributes
The store-wide vocabulary a product's specification table is written in. The nine types, the validation messages and the value contract are on Products and catalog.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/attributes |
kahunacart.products.manage |
Every definition, plus types, filterable_types and option_types |
POST |
/kahunacart/attributes |
kahunacart.products.manage |
Create a definition. label and type are required |
POST |
/kahunacart/attributes/reorder |
kahunacart.products.manage |
{ids: [...]} puts the whole list in that order |
GET |
/kahunacart/attributes/{id} |
kahunacart.products.manage |
One definition |
PATCH |
/kahunacart/attributes/{id} |
kahunacart.products.manage |
Update it. Only what is sent is touched |
DELETE |
/kahunacart/attributes/{id} |
kahunacart.products.manage |
Delete it. {force: true} takes the values too |
Settable fields are slug, label, type, options, unit, group_label, position, filterable and show_on_product. Deleting a definition products still answer is a 409 naming the count; the same request with force succeeds and reports products_affected. Values live on the product, through the attributes map on a product body — see Attribute values over the API.
Tax
Tax zones and the rates hanging off them. See Tax.
| Method | Path | Permission | What it does |
|---|---|---|---|
POST |
/kahunacart/tax/presets/eu-vat |
kahunacart.settings |
Import the EU VAT presets. Answers imported |
GET |
/kahunacart/tax/zones |
kahunacart.settings |
Every zone with its rates |
POST |
/kahunacart/tax/zones |
kahunacart.settings |
Create a zone, rates included |
PATCH |
/kahunacart/tax/zones/{id} |
kahunacart.settings |
Update a zone. A rates key replaces the whole set |
DELETE |
/kahunacart/tax/zones/{id} |
kahunacart.settings |
Delete the zone and its rates |
Zone fields are name, countries, regions, postcodes and priority. A rate takes rate (a decimal string, required), label, tax_class, apply_to_shipping and priority. The preset import is idempotent by zone name, so imported is what was actually added.
Shipping
Shipping zones and the methods they offer. See Shipping.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/shipping/zones |
kahunacart.settings |
Every zone with its methods |
POST |
/kahunacart/shipping/zones |
kahunacart.settings |
Create a zone, methods included |
PATCH |
/kahunacart/shipping/zones/{id} |
kahunacart.settings |
Update a zone. A methods key replaces the whole set |
DELETE |
/kahunacart/shipping/zones/{id} |
kahunacart.settings |
Delete the zone and its methods |
Zone fields are name, countries, regions, postcodes and position. A method takes type (flat, free or pickup), label, enabled, cost, per_class, weight_rules, min_total and position. An unknown type is a 422. min_total is genuinely nullable: an empty value means "always offered", which a stored zero would not say. Methods are re-inserted on every zone update, so their ids change — send their translations back in the payload to carry the overrides across.
Coupons
Codes a shopper types in. See Coupons.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/coupons |
kahunacart.settings |
List coupons. Takes page, per_page (100 by default) |
POST |
/kahunacart/coupons |
kahunacart.settings |
Create a coupon. A duplicate code is a 409 |
PATCH |
/kahunacart/coupons/{id} |
kahunacart.settings |
Update a coupon |
DELETE |
/kahunacart/coupons/{id} |
kahunacart.settings |
Delete it. The redemption log is left alone |
Fields are code, type (percent, fixed_cart or fixed_product), percent, amount, min_total, max_total, starts_at, expires_at, usage_limit, usage_limit_per_user, enabled, individual_use and product_ids. Every optional numeric field is nullable, so an empty value clears the column rather than writing a zero.
Sales
Automatic discounts with no code involved. A sale is a header plus a list of rules that are only meaningful together, so a rule has no route of its own.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/sales |
kahunacart.sales.manage |
Every sale with its rules, plus total |
POST |
/kahunacart/sales |
kahunacart.sales.manage |
Create a sale. name and label are both required |
GET |
/kahunacart/sales/{id} |
kahunacart.sales.manage |
One sale |
PATCH |
/kahunacart/sales/{id} |
kahunacart.sales.manage |
Update it. A rules key replaces the whole set |
DELETE |
/kahunacart/sales/{id} |
kahunacart.sales.manage |
Delete the sale, its rules and its translations |
Header fields are name (the merchant's handle), label (the badge a shopper reads), starts_at, expires_at, priority, enabled, stack_with_coupons and translations. A rule takes scope, scope_id, mode, percent, amount and exclude; a rule's position comes from its place in the list, not from a position field. Omitting rules from a PATCH leaves them standing, so switching a sale off is one field. Each rule comes back with both amount_minor and a decimal amount.
Reports
Read-only, on their own permission, so a bookkeeper can be given the numbers and nothing else.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/reports/summary |
kahunacart.reports |
Headline figures and the daily series |
GET |
/kahunacart/reports/products |
kahunacart.reports |
What sold, by product and variant |
GET |
/kahunacart/reports/customers |
kahunacart.reports |
Who bought, new against returning |
GET |
/kahunacart/reports/taxes |
kahunacart.reports |
Tax collected, per label and rate |
GET |
/kahunacart/reports/refunds |
kahunacart.reports |
Every refund in the range, and the refund rate |
GET |
/kahunacart/reports/export |
kahunacart.reports |
The range as a CSV ledger |
GET |
/kahunacart/reports/export.pdf |
kahunacart.reports |
The range as a PDF summary |
GET |
/kahunacart/reports/low-stock |
kahunacart.reports |
Variants at or below a stock threshold |
All but low-stock take ?from=YYYY-MM-DD&to=YYYY-MM-DD and default to the last 30 days. Every JSON payload repeats from, to and currency so a response can be read without the request beside it.
products, customers and refunds also take ?page= and answer with total, page and per_page; taxes does not, because a store has a handful of tax labels. Their totals block covers the whole range, not the page.
productsanswers{products: [{product_id, title, units, revenue_minor, refunded_minor, variants: [{variant_id, title, sku, units, revenue_minor, refunded_minor}]}], totals: {products, units, revenue_minor, refunded_minor}}, sorted by revenue.customersanswers{customers: [{customer_id, email, name, orders, revenue_minor, aov_minor, first_order_at, last_order_at, is_new}], totals: {customers, orders, revenue_minor, aov_minor}, new_customers, returning_customers, new_revenue_minor, returning_revenue_minor}.customer_idis null for a guest checkout.taxesanswers{rows: [{label, rate, included, orders, base_minor, tax_minor}], totals: {orders, base_minor, tax_minor, included_minor}, engine: {zones, flat_rate, flat_label}}.enginesays whether the store is running zones or the flat rate, so an empty table can be explained.refundsanswers{refunds: [{id, created_at, amount_minor, provider, status, reason, reference, order_id, order_number, email, customer_id}], totals: {refunds, refunded_minor}, revenue_minor, rate_bp}, newest first. Every refund is listed whatever became of it, but only successful ones count towardtotalsandrate_bp.rate_bpis basis points (250 is 2.5%) and null when there was no revenue to measure against.low-stocktakes?threshold=5and answers{items: [...], threshold}. A threshold of 0 asks only for what has run out; a negative one clamps to zero.
export sends text/csv with a filename naming the range, and includes the refunded orders that summary drops. export.pdf sends application/pdf, and answers 500 with the renderer's own message when the PDF library is missing.
Orders
The commercial record. Statuses, the timeline and the refund rules are on Orders and the admin.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/badge |
kahunacart.orders.view |
Orders awaiting payment, plus job-queue health |
GET |
/kahunacart/orders |
kahunacart.orders.view |
List completed orders. Takes q, page, per_page |
POST |
/kahunacart/orders |
kahunacart.orders.manage |
Enter an order by hand |
GET |
/kahunacart/orders/{id} |
kahunacart.orders.view |
One order with items, adjustments, transactions and timeline |
POST |
/kahunacart/orders/{id}/refund |
kahunacart.orders.refund |
Refund. {amount} optional; omitted refunds the rest |
POST |
/kahunacart/orders/{id}/mark-paid |
kahunacart.orders.manage |
Settle an offline payment |
POST |
/kahunacart/orders/{id}/fulfill |
kahunacart.orders.manage |
Mark the whole order shipped |
POST |
/kahunacart/orders/{id}/cancel |
kahunacart.orders.manage |
Cancel an unpaid order and reverse what completion took |
POST |
/kahunacart/orders/{id}/resend-confirmation |
kahunacart.orders.manage |
Queue the confirmation email again |
POST /kahunacart/orders takes {email, name?, note?, items: [{variant_id, qty}]}, records the order against the offline provider as unpaid, and answers 201 with the order and its line items. An unpublished or out-of-stock variant is a 422 carrying the reason.
refund answers {status, refunded_minor, pending_refund_minor}, where status is confirmed (the money is on its way back) or pending (the provider took the request and has not settled it). A refused refund is a 422.
mark-paid, fulfill and cancel each answer 409 when the order is in the wrong state, with a sentence saying which: payment not pending, a digital-only order with nothing to ship, an order already fulfilled or already canceled, or a paid order that must be refunded rather than canceled.
Order downloads
The download grants on an order, and the kill switch for one.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/orders/{id}/downloads |
kahunacart.orders.view |
Every grant on the order, each with its file_name |
POST |
/kahunacart/orders/{id}/downloads/{grantId}/revoke |
kahunacart.orders.manage |
Revoke one grant. Answers the updated grant |
Revoking leaves the row in place — who was given what, and when it was taken away, is the history worth keeping. There is no un-revoke; reinstating access means granting again. See Digital products and downloads.
Customers
The commercial record is read-only; the sign-in account is not. See Customer accounts.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/customers |
kahunacart.customers.view |
List customers with stats. Takes q, page, per_page |
GET |
/kahunacart/customers/{id} |
kahunacart.customers.view |
One customer with orders, addresses, account status and add-on panels |
POST |
/kahunacart/customers/{id}/invite |
kahunacart.customers.manage |
Open the account if needed, then queue the set-password email |
POST |
/kahunacart/customers/{id}/clear-2fa |
kahunacart.customers.manage |
Destroy a lost authenticator's secret |
POST |
/kahunacart/customers/{id}/state |
kahunacart.customers.manage |
Enable or disable the account: {"state": "enabled"} or "disabled" |
The list answers {customers, total, page, per_page, db_identity}, and each row carries an account value when the store keeps customer identities in its own database. The read answers {customer, orders, addresses, account, db_identity, panels}, where customer is the row plus its lifetime stats and panels is whatever add-ons contribute on the admin channel.
Nothing here ever returns a hash, a secret or a live token; the account object says whether those exist, not what they are. All three account actions answer 409 on a store that is not keeping customer accounts in its database, and invite answers 409 when a Grav user account already holds that email address. state refuses anything but enabled or disabled with a 422.
Webhook log
Read-only delivery history for incoming provider webhooks. See Payment providers.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/webhooks/summary |
kahunacart.settings |
Per-provider counts, failure streaks and retention_days |
GET |
/kahunacart/webhooks |
kahunacart.settings |
Recent deliveries, newest first. Takes provider, page, per_page |
A delivery carries id, provider, received_at, status_code, succeeded, action, reference, order_id, error, payload_bytes and payload_excerpt. succeeded is computed rather than derived from the status code, so the API and the CLI agree on what a good delivery is. Nothing here writes: there is no retry endpoint and no way to disable a provider on the strength of a streak.
Providers
The payment methods this store offers, and the operator actions against one. Configuration itself stays in each provider plugin's own Grav blueprint.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/providers |
kahunacart.settings |
Every registered provider and what it can do |
POST |
/kahunacart/providers/{slug}/check |
kahunacart.settings |
Ask the provider whether its credentials work |
POST |
/kahunacart/providers/{slug}/sync |
kahunacart.settings |
Push the catalogue to the provider, or pull it back. {"direction": "pull"}; push is the default |
POST |
/kahunacart/providers/{slug}/webhook |
kahunacart.settings |
Register this store's webhook endpoint with the provider |
POST |
/kahunacart/providers/{slug}/default |
kahunacart.settings |
Make this the method checkout starts on |
list answers {providers: [{slug, label, capabilities, can_check, can_setup_webhook, is_default, can_sync, sync_direction}]}. No credential ever appears in a response.
check and sync always answer 200 when the provider answered at all — a failing check is a successful request reporting a failure, and the provider's own words are what a merchant needs. sync answers {slug, direction, ok, count, summary}, and a network failure comes back as ok: false with the exception's message rather than a 500. A provider that cannot be asked at all is a 422; a slug nothing registers is a 404.
webhook takes an optional {reset: true}, which deletes this store's existing endpoints before registering. Whatever signing secret comes back is written to the provider plugin's config file and never returned to the caller. default writes checkout.default_provider on the base plugin. Configured sync direction is not consulted by sync: off means "do not sync automatically", not "refuse an operator who asked".
Admin sections
The extension point provider plugins use to add their own screens. The component contract is on Admin sections.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/admin/sections |
kahunacart.orders.view |
{sections: [{id, label, icon, position}]} |
GET |
/kahunacart/admin/sections/{id}.js |
kahunacart.orders.view |
The section's component source |
GET |
/kahunacart/admin/widget.js |
kahunacart.reports |
The dashboard widget's script |
script_path is a server-side detail and never appears in a response; a script is fetched by id instead. Both script routes answer application/javascript with an ETag built from mtime and size, and a conditional GET with 304. A path that resolves outside the plugins directory is refused with 404, and the widget route answers 404 while the file does not exist.
Licenses (add-on)
Provided by the KahunaCart Licenses plugin. One permission covers the whole admin API, because a licence key is the credential itself and reading the list is already handing out the goods.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/licenses |
kahunacart.licenses |
Find keys. Takes q, status, product_id, variant_id, customer_id, order_id, page, per_page |
POST |
/kahunacart/licenses |
kahunacart.licenses |
Issue a key by hand. product_id is required |
GET |
/kahunacart/licenses/{id} |
kahunacart.licenses |
One key with its activations, catalogue, customer and delivery copy |
PATCH |
/kahunacart/licenses/{id} |
kahunacart.licenses |
Edit the key's terms |
POST |
/kahunacart/licenses/{id}/suspend |
kahunacart.licenses |
Switch the key off. {note} optional. Seats are left alone |
POST |
/kahunacart/licenses/{id}/reinstate |
kahunacart.licenses |
Switch it back on |
POST |
/kahunacart/licenses/{id}/revoke |
kahunacart.licenses |
Kill it permanently. {reason} is required |
POST |
/kahunacart/licenses/{id}/regenerate |
kahunacart.licenses |
Replace a leaked key. The old one is revoked, not deleted |
DELETE |
/kahunacart/licenses/{id}/activations/{activationId} |
kahunacart.licenses |
Free a seat on the customer's behalf |
GET |
/kahunacart/licenses/products |
kahunacart.licenses |
Every licensing rule, the catalogue, and the placeholder legend |
PUT |
/kahunacart/licenses/products/{productId} |
kahunacart.licenses |
Save the product-wide rule |
PUT |
/kahunacart/licenses/products/{productId}/{variantId} |
kahunacart.licenses |
Save one variant's own rule |
DELETE |
/kahunacart/licenses/products/{productId} |
kahunacart.licenses |
Remove the product-wide rule |
DELETE |
/kahunacart/licenses/products/{productId}/{variantId} |
kahunacart.licenses |
Remove one variant's rule |
GET |
/kahunacart/licenses/settings |
kahunacart.licenses |
Store-wide settings, signing secret masked |
PATCH |
/kahunacart/licenses/settings |
kahunacart.licenses |
Save settings. Partial, and each nested block merges |
POST |
/kahunacart/licenses/settings/rotate-secret |
kahunacart.licenses |
Mint a new response-signing secret and show it once |
GET /kahunacart/licenses answers {licenses, total, page, per_page, statuses}. A refusal from the licensing service carries a stable machine code in the body and in an X-KahunaCart-Error header. Both settings writes answer 405 on an installation with nowhere to write plugin configuration; the read response says so up front on its writable flag.
Seven more endpoints under /kahunacart/licenses/public/ answer software on a customer's machine with no account and no permission — validate, activate, deactivate, updates, file, latest and release. They are documented on Licenses.
Subscriptions (add-on)
Provided by the KahunaCart Subscriptions plugin, on a view and manage pair. The actions and what each one means commercially are on Subscriptions.
| Method | Path | Permission | What it does |
|---|---|---|---|
GET |
/kahunacart/subscription-plans/variants |
kahunacart.subscriptions.view |
Every variant, and which already has a plan |
GET |
/kahunacart/subscription-plans |
kahunacart.subscriptions.view |
Every plan |
POST |
/kahunacart/subscription-plans |
kahunacart.subscriptions.manage |
Create a plan |
GET |
/kahunacart/subscription-plans/{id} |
kahunacart.subscriptions.view |
One plan |
PATCH |
/kahunacart/subscription-plans/{id} |
kahunacart.subscriptions.manage |
Update a plan |
DELETE |
/kahunacart/subscription-plans/{id} |
kahunacart.subscriptions.manage |
Delete a plan |
PUT |
/kahunacart/subscription-plans/{id}/provider-refs/{provider} |
kahunacart.subscriptions.manage |
Attach a plan to a price you created at the platform |
GET |
/kahunacart/subscriptions/metrics |
kahunacart.subscriptions.view |
Recurring revenue, active, trialling, past due, paused |
GET |
/kahunacart/subscriptions |
kahunacart.subscriptions.view |
List subscriptions |
GET |
/kahunacart/subscriptions/{id} |
kahunacart.subscriptions.view |
One subscription with its history |
POST |
/kahunacart/subscriptions/{id}/cancel |
kahunacart.subscriptions.manage |
{at_period_end} defaults to true, {reason} optional |
POST |
/kahunacart/subscriptions/{id}/uncancel |
kahunacart.subscriptions.manage |
Clear a scheduled cancellation |
POST |
/kahunacart/subscriptions/{id}/pause |
kahunacart.subscriptions.manage |
Stop the billing calendar. {reason} optional |
POST |
/kahunacart/subscriptions/{id}/resume |
kahunacart.subscriptions.manage |
Restart it |
POST |
/kahunacart/subscriptions/{id}/extend |
kahunacart.subscriptions.manage |
{days, note}. Both required; days may be negative |
POST |
/kahunacart/subscriptions/{id}/note |
kahunacart.subscriptions.manage |
{note} on the timeline. Changes nothing |
POST |
/kahunacart/subscriptions/{id}/resend-pay-link |
kahunacart.subscriptions.manage |
Queue the outstanding invoice's email again |
extend refuses a zero-day extension, one over the cap, or one with no note, each with a 422 saying which. resend-pay-link is invoice mode only and answers 422 on a provider-billed subscription, because there is no pay link to send. A refusal from the state machine carries its reason as a sentence and a machine code in the X-KahunaCart-Error header.
Examples
Every example uses https://example.com/api/v1 as the base and an API key. Swap in -H "X-API-Token: $TOKEN" to use a JWT instead.
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 }
]
}'
The response is 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 spoken for.
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