For developers
The shop API
If you run a shop on kedai.store and want your own software to read it, this is the API for that: print today's orders from a script, push your stock to another site, or pull sales into a spreadsheet. It is version 1, and version 1 only reads — but the same key can also run the shop, which is further down this page.
What a key is
A key is how your software asks kedai.store a question. You make one on the API keys screen, and it looks like this:
ks_live_<12 characters>_<40 characters>
The first part identifies the key and the second part proves it. The secret is shown exactly once, the moment the key is made, and only a fingerprint of it is kept afterwards. If it is lost it cannot be recovered: revoke that key and mint another.
A key belongs to one shop and carries a set of abilities, which are the same permission codes the staff roles use, such as orders.view. It reaches exactly what its abilities allow and can never reach a different shop, and there is no person behind it: it does not carry a login. Reading needs a .view ability and changing something needs a .manage one, so a key scoped for reading cannot write. Ask the shopkeeper for a key with the narrowest set of abilities the script needs.
How to send it
The whole key goes as a bearer token on every request. There is no session, no cookie and no CSRF token: every request stands on its own.
Authorization: Bearer <the whole key> Accept: application/json
The base of the API is https://kedai.store/api/v1. A first request, with the key in a variable so it stays out of your shell history:
export KEDAI_KEY="<paste the key here>" curl -s https://kedai.store/api/v1/shop \ -H "Authorization: Bearer $KEDAI_KEY" \ -H "Accept: application/json"
- Do not send
X-Shop-Id. A key decides its own shop, and a header naming a different one is refused with a400rather than quietly answered. Silently reading the wrong shop is worse than a refusal. - A portal session token will not work here. A key and a session are two different doors, and each is refused at the other.
The endpoints
Every endpoint in this table is a GET, because this is version 1 of the read API and it writes nothing. Writing is a second surface with its own rules: your AI can run the shop, further down this page.
| Method | Path | Ability the key needs | Returns |
|---|---|---|---|
| GET | /shop | settings.view | The shop |
| GET | /products | products.view | The catalogue, paged |
| GET | /products/{id} | products.view | One product |
| GET | /orders | orders.view | The orders, paged |
| GET | /orders/{reference} | orders.view | One order |
| GET | /subscriptions | subscriptions.view | The subscribers and their state, paged |
| GET | /shipping | shipping.view | The whole shipping setup |
If the key lacks the ability an endpoint needs, the answer is 403 with the code it wanted, which is the same refusal a member of the shop would get. Check the key's abilities before blaming the request.
Lists, and polling for changes
The list endpoints share three query parameters:
per_page: How many rows per page. Default 20, capped at 100. Asking for more than the cap is answered with the cap rather than refused.page: Which page to read, counting from 1.updated_since: An ISO 8601 timestamp. Only rows changed at or after it are returned. The comparison is inclusive. Poll with this instead of reading the whole shop.
orders and subscriptions also take a status filter, narrowed to the states each one knows. To keep in step without reading the whole shop every run, store the newest updated_at you have seen and send it back as updated_since.
Money is in sen
Every amount is an integer in sen, in a field whose name ends _minor. That integer is the truth. A formatted string travels beside it for display only, and is never the thing to parse or to sum.
"total_minor": 12345, "total": "RM 123.45"
12345 is RM123.45. To add up a basket, add the _minor integers and ignore the strings. Some amounts can be null, and null is not zero: a product with no price is answered price_minor: null, which means no price was set rather than a free item.
Errors
Every error has the same shape: a machine code, a sentence message, and, when one field is to blame, a field naming it.
{
"code": "invalid_parameter",
"message": "The per page field must be an integer.",
"field": "per_page"
}The codes the API can answer with:
- shop_mismatch
- missing_api_key
- invalid_api_key
- revoked_api_key
- expired_api_key
- forbidden
- not_found
- method_not_allowed
- invalid_parameter
- rate_limited
A 401 is never a reason to retry, and it is never the server's fault: check the key is right, that it has not been revoked and that it has not expired. A 404 for a row you expect to exist is most often a row in a different shop, since a key only ever sees its own.
The rate limit
Each key is allowed 60 requests a minute. The allowance is counted per key, so one busy key does not spend another's. When it is used up the answer is 429 with these headers:
Retry-After: the number of seconds to wait before trying again.X-RateLimit-Limit: the ceiling, 60.X-RateLimit-Remaining: how many requests are left in this window.
Wait the seconds in Retry-After before sending again. A well behaved script backs off rather than hammering the endpoint.
Your AI can run the shop
Everything above is reading. The same key also runs the shop: it adds and edits products, moves an order through fulfilment, publishes a theme — 87 operations in all. That is what lets a shopkeeper hand the shop to an assistant and ask for the work in a sentence instead of clicking through screens.
The endpoint
https://kedai.store/api/mcp
JSON-RPC 2.0, protocol revision 2025-06-18, with the whole key as a bearer token — sent exactly as above. There is no session and no cookie here either.
Point an AI client at it
Any client that speaks the Model Context Protocol over HTTP can be given the endpoint and the key. The block below is the shape they mostly share, but the field names differ a little between clients; the two things that matter are the URL and the Authorization header.
{
"mcpServers": {
"kedai-store": {
"type": "http",
"url": "https://kedai.store/api/mcp",
"headers": { "Authorization": "Bearer <your key>" }
}
}
}- A client that only speaks
stdiocannot reach an HTTP endpoint on its own and needs a small bridge process in front of it. The URL and the header are still the only two things to give it. - The
typeis sometimes calledstreamable-http, and some clients take the URL asserverUrl. If yours refuses the block, the field name is what to change — not the endpoint.
An AI that would rather make plain HTTP calls needs no protocol at all. Every operation below is also ordinary REST — POST https://kedai.store/api/mcp/products with a bearer key and a JSON body does what the tool does:
curl -s https://kedai.store/api/mcp/products \
-H "Authorization: Bearer <the key you minted>" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"name": "Kopi Takeaway", "price_minor": 1200}'Clients that want a schema instead of a protocol
ChatGPT and the other assistants that take an OpenAPI document rather than speak a protocol read the same operations from one URL. Point a Custom GPT Action — or any function-calling framework, or a Postman collection — at:
https://kedai.store/api/mcp/openapi.json
It names every operation, the ability each one needs, and the refusals a caller can correct. Some clients cap how many operations one Action may hold, and this surface is larger than that cap — add ?area=products to take one area at a time, and read x-areas in the document for the list to choose from.
The tools an AI gets
These are named, described and callable over the protocol, which is what makes an assistant able to use them without being told how. Each needs the ability beside it.
| Tool | What it does | Ability the key needs |
|---|---|---|
| get_shop_theme | Read the shop's current appearance. Returns the LAYOUT theme the shop presents, the theme row it is wearing (its id, key, status and version), the tokens a shopper is served, that row's stored tokens and validated composition, and the shop's whole theme library with which row is active. Call this first to learn what a shop already looks like before composing anything. It reads only the shop the caller's token belongs to; there is no argument for naming a different shop. Pass `theme_id` only to inspect a specific row from the library that this same call returns. | appearance.view |
| list_theme_options | List every appearance token this platform accepts: name, kind, limits and whether it becomes a CSS variable on the storefront. Use before `mcp_shop_appearance_tokens` or `save_shop_theme_draft` token fields. | appearance.view |
| validate_theme_options | Validate candidate tokens and/or settings without saving. Returns field-keyed errors or `valid: true`. Same allowlists as `mcp_shop_appearance_tokens`. | appearance.view |
| list_sections | List the section types a shop may place on its home page — the component library the platform owns. Each entry carries the type key, a human label, and the fields it accepts (each with its kind and limits). Also returns the five link types and the maximum number of sections one page may hold. A shop arranges these components; it never writes one. Use `get_section_schema` for one type's fields in detail before composing, and `save_shop_theme_draft` to write a composition built from them. | appearance.view |
| get_section_schema | Return one section type's field schema: the fields it accepts, each with its kind (text, textarea, image, link, select, number, bool, list), its limits, whether it is required, and its default when it has one. `type` must be one of the keys `list_sections` returns. A type the platform does not ship is refused rather than guessed at. | appearance.view |
| save_shop_theme_draft | Validate a home-page composition and write it as a DRAFT for this shop. The draft is invisible to shoppers: this tool never publishes and never changes which theme the shop is wearing. It writes a new draft row by default, or updates an existing row when you pass `theme_id`. Saving a draft over the row the shop is currently WEARING will take that row out of service (it becomes a draft) — reach for this only on a row you mean to work on, and publish it deliberately once a person has approved it. `sections` is the whole composition, in order, built from the types `list_sections` returns. Each section is `{"type": "...", "fields": { ... }}`, where the fields are exactly that type's schema. `tokens` is optional and must use only the allowlisted token names — unknown names and bad values are refused, not dropped. A `javascript:` link, an unknown section type, an unknown field and another shop's media id are all refused whole, with the platform validator's own field-keyed errors. | appearance.manage |
| preview_shop_theme | Render this shop's real storefront with a candidate appearance applied, WITHOUT saving anything. Pass candidate `tokens` and/or a candidate `sections`; anything you omit falls back to what the shop already has, so previewing only a colour still shows the shop's own home page. This writes no draft and changes nothing — it is the safe way to look at work before it is saved or published. The tool does NOT return the page's HTML (a composed storefront page is far too large for a tool result); it returns a short description of what was rendered — the layout used, how many sections and tokens were applied, the size of the page — so you can confirm the candidate renders. A candidate a save would refuse (an unknown section type, a `javascript:` link, a bad token) is refused here too, with the same errors. | appearance.view |
| publish_shop_theme | Publish a theme: make it the one this shop wears, live on its storefront. This is the ONLY tool on this surface that puts work in front of shoppers. It publishes the named theme row and points the shop at it in one act, so the change is immediate and reaches every visitor. Do NOT call this on your own initiative: call it only when the person you are working for has asked you to publish, and only after they have seen the result through `preview_shop_theme`. A draft is where your work belongs until a person says otherwise. `theme_id` is required and must be a row of this shop, from the `library` returned by `get_shop_theme`. | appearance.manage |
| generate_shop_theme | Generate a DRAFT shop theme from the owner's own words, using the platform's constrained AI writer. Pass the owner's sentence verbatim — whatever language and register it arrived in — and a tier: tune the look only: colours, corner rounding and catalogue fonts. Cheap, low risk. compose the look AND an arranged home page drawn from the platform's own sections. Returns a draft payload: `label`, `tokens`, `sections`, `source = "ai"`, `status = "draft"`. It writes NOTHING and publishes nothing — the model's answer is a draft by construction. If it should be kept, save it with `save_shop_theme_draft`; a person decides when to publish. The model may only emit the platform's own section types, allowlisted token names, catalogue font keys and typed links. An answer that names anything else is refused whole, never repaired, and comes back as an error here. | appearance.manage |
| validate_kit | Validate a candidate KIT — tokens plus sections — and return the platform's own verdict, without saving anything. Use this while authoring: it runs the exact validator the storefront runs, so a kit that comes back valid will be accepted by `save_shop_theme_draft`, and one that does not comes back with the platform's field-keyed errors (`sections.0.fields.cta_link`, and so on). Passing `valid: false` is the tool working, not failing — read `section_errors` and `token_errors` for what to fix. Section rules worth remembering: only the types `list_sections` returns; only the fields a type declares; images are ids of THIS shop's own media; and links are typed references (`product`/`category`/`page`/`search`/`external`) — an `external` url must be http or https, so a `javascript:` url is refused. | appearance.view |
| preview_kit | Render a candidate KIT — tokens plus sections — through this shop's real storefront, without saving anything. Use it after `validate_kit` to look at the result on real content. Both `tokens` and `sections` are optional; a kit with neither renders the shop as it stands. The candidate is validated exactly as a save would be, so a kit a save would refuse is refused here too. This writes no draft, activates no theme and changes nothing. It does not return the page's HTML (too large for a tool result); it returns a short description of what was rendered, so you can confirm the kit draws. | appearance.view |
Every operation, under the ability it needs
This is the list to read while ticking boxes on the key screen. A key holding products.manage gets every line in that group and nothing from the others.
appearance.manage— 15 operations
POST/api/mcp/shop/themePUT/api/mcp/shop/appearance/tokensPOST/api/mcp/shop/appearance/logoDELETE/api/mcp/shop/appearance/logoPOST/api/mcp/shop/appearance/faviconDELETE/api/mcp/shop/appearance/faviconPUT/api/mcp/shop/appearance/sectionsPOST/api/mcp/shop/appearance/mediaDELETE/api/mcp/shop/appearance/media/{media}POST/api/mcp/shop/appearance/themesPATCH/api/mcp/shop/appearance/themes/{theme}POST/api/mcp/shop/appearance/themes/{theme}/activatePOST/api/mcp/shop/appearance/themes/{theme}/archiveDELETE/api/mcp/shop/appearance/themes/{theme}POST/api/mcp/shop/appearance/generate
appearance.view— 1 operations
GET/api/mcp/shop/appearance/preview
categories.manage— 4 operations
POST/api/mcp/categoriesPATCH/api/mcp/categories/{category}DELETE/api/mcp/categories/{category}POST/api/mcp/categories/media
fulfilment.manage— 8 operations
POST/api/mcp/orders/{order}/boxesPOST/api/mcp/boxes/bulkPOST/api/mcp/boxes/bookPATCH/api/mcp/boxes/{box}POST/api/mcp/boxes/{box}/packPOST/api/mcp/boxes/{box}/shipPOST/api/mcp/boxes/{box}/bookPOST/api/mcp/boxes/{box}/deliver
loyalty.manage— 8 operations
PUT/api/mcp/loyaltyPOST/api/mcp/loyalty/rewardsPATCH/api/mcp/loyalty/rewards/{id}DELETE/api/mcp/loyalty/rewards/{id}POST/api/mcp/loyalty/tiersPATCH/api/mcp/loyalty/tiers/{id}DELETE/api/mcp/loyalty/tiers/{id}PUT/api/mcp/loyalty/referral
menus.manage— 4 operations
POST/api/mcp/menusPATCH/api/mcp/menus/{menu}PUT/api/mcp/menus/{menu}/itemsDELETE/api/mcp/menus/{menu}
orders.manage— 4 operations
POST/api/mcp/orders/{order}/paidPOST/api/mcp/orders/{order}/cancelPOST/api/mcp/salesPOST/api/mcp/sales/{sale}/refunds
pages.manage— 3 operations
POST/api/mcp/pagesPATCH/api/mcp/pages/{page}DELETE/api/mcp/pages/{page}
posts.manage— 8 operations
POST/api/mcp/postsPATCH/api/mcp/posts/{post}DELETE/api/mcp/posts/{post}POST/api/mcp/posts/{post}/publishPOST/api/mcp/posts/{post}/unpublishPOST/api/mcp/post-categoriesPATCH/api/mcp/post-categories/{post_category}DELETE/api/mcp/post-categories/{post_category}
products.manage— 14 operations
POST/api/mcp/productsPATCH/api/mcp/products/{product}DELETE/api/mcp/products/{product}POST/api/mcp/products/{product}/linksDELETE/api/mcp/products/{product}/links/{link}POST/api/mcp/products/{product}/imagesPOST/api/mcp/products/{product}/images/orderPATCH/api/mcp/products/{product}/images/{image}DELETE/api/mcp/products/{product}/images/{image}POST/api/mcp/volume-laddersPATCH/api/mcp/volume-ladders/{ladder}POST/api/mcp/volume-ladders/{ladder}/activatePOST/api/mcp/volume-ladders/{ladder}/stopDELETE/api/mcp/volume-ladders/{ladder}
promotions.manage— 6 operations
POST/api/mcp/promotionsPATCH/api/mcp/promotions/{promotion}POST/api/mcp/promotions/{promotion}/activatePOST/api/mcp/promotions/{promotion}/stopPOST/api/mcp/promotions/{promotion}/codesDELETE/api/mcp/promotions/{promotion}/codes/{code}
reviews.moderate— 2 operations
POST/api/mcp/reviews/{review}/approvePOST/api/mcp/reviews/{review}/reject
settings.manage— 6 operations
POST/api/mcp/shop/settingsPOST/api/mcp/shop/closedDELETE/api/mcp/shop/closed/backgroundPOST/api/mcp/shop/domainDELETE/api/mcp/shop/domainPOST/api/mcp/shop/domain/verify
shipping.manage— 1 operations
POST/api/mcp/shop/shipping
subscriptions.manage— 3 operations
POST/api/mcp/subscriptions/{subscription}/pausePOST/api/mcp/subscriptions/{subscription}/resumePOST/api/mcp/subscriptions/{subscription}/cancel
What a key can never do
An AI with a key can run a shop. It cannot become the shopkeeper. These are refused however the key is scoped, because they are the powers the platform decided belong to the person and to no program:
wallet.topup— Top up the wallet. putting money in is the shopkeeper's own actbilling.manage— Billing and closing the shop. the card on file, the plan, and closing the shopbank.manage— Payment, payout and courier accounts. the shop's own CHIP and courier credentials, and its payout accountmembers.transfer— Hand the shop over. handing the shop to somebody else
It cannot reach these parts of the API either, and each of these is a decision rather than an oversight:
/api/shop/api-keys— A key that can mint keys can widen itself past the codes it holds./api/shop/oauth— Consenting is a person's decision on a screen: a key that could reach it would grant itself a connection./api/lead-campaigns— Sending a campaign mails real people and cannot be undone: it is the owner's act on the screen, not a tool a model may fire./api/members— Identity: who may act for the shop is a person's decision./api/roles— Identity: the roles a key's codes are drawn from./api/auth— The caller's own session, not the shop's work./api/account— The caller's own account, not the shop's work./api/email— The caller's own address, not the shop's work./api/admin— The platform operator's console, which is not a shop at all./api/onboarding— Onboarding runs before a shop exists, and a key belongs to one.
Sending the same write twice
An assistant that loses its connection cannot tell whether the product was created. So a write may carry an Idempotency-Key header: if the same key arrives twice, the second request is answered with the first request's answer rather than doing the work again, and the reply carries Idempotent-Replay: true so the caller can tell a repeat from a fresh write.
A write that was refused frees its key, so a request corrected after a 422 may send the same one again.
What version 1 does not do
- Version 1 of the read API writes nothing. Writing exists, on the agent surface above, which is a different door with its own boundary rather than a later version of this one.
- No webhooks. Poll
updated_sinceinstead. - No OAuth, and no acting for a shop without holding a key.
- No per endpoint control beyond the abilities on the key.
- Nothing on the storefront. This is the shop's own API, not the shopper's.
The document your tooling should read is https://kedai.store/api/openapi.json.
Questions about your shop's API: ask whoever minted your key, or write to us.