Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

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 example 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 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:

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

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

JSON
{ "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 0enabled, 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.

  • products answers {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.
  • customers answers {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_id is null for a guest checkout.
  • taxes answers {rows: [{label, rate, included, orders, base_minor, tax_minor}], totals: {orders, base_minor, tax_minor, included_minor}, engine: {zones, flat_rate, flat_label}}. engine says whether the store is running zones or the flat rate, so an empty table can be explained.
  • refunds answers {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 toward totals and rate_bp. rate_bp is basis points (250 is 2.5%) and null when there was no revenue to measure against.
  • low-stock takes ?threshold=5 and 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.

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 }
    ]
  }'

The response is 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 spoken for.

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