{
  "openapi": "3.0.3",
  "info": {
    "title": "kedai.store shop API",
    "version": "1.0.0",
    "description": "The read only API a shop reaches with its own key.\n\nA shop mints a key on the API keys screen of the owner portal, and its own software uses it\nto read that shop: products, orders, subscriptions and the shipping setup.\n\nA key authenticates a shop, not a person. It carries a set of abilities, which are the same\npermission codes a staff role carries, and it can never read a different shop.\n\nVersion 1 is read only. Every endpoint is a GET.\n\nThe prose guide, with worked `curl` examples and the full error table, is published at\nhttps://kedai.store/docs/api.\n",
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://kedai.store/api/v1",
      "description": "Production"
    }
  ],
  "x-complete-document": "https://kedai.store/api/openapi.key.json",
  "x-write-document": "https://kedai.store/api/mcp/openapi.json",
  "tags": [
    {
      "name": "Shop",
      "description": "The shop the key belongs to."
    },
    {
      "name": "Catalogue",
      "description": "Products, including their choices."
    },
    {
      "name": "Orders",
      "description": "Orders taken by the shop."
    },
    {
      "name": "Subscriptions",
      "description": "Standing instructions to charge again."
    },
    {
      "name": "Shipping",
      "description": "Where the shop posts a parcel, and what it charges."
    }
  ],
  "security": [
    {
      "bearerKey": []
    }
  ],
  "paths": {
    "/shop": {
      "get": {
        "tags": [
          "Shop"
        ],
        "operationId": "getShop",
        "summary": "The shop",
        "description": "The shop the key belongs to, as a customer of it would know it.",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The shop.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Shop"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "settings.view"
      }
    },
    "/products": {
      "get": {
        "tags": [
          "Catalogue"
        ],
        "operationId": "listProducts",
        "summary": "The catalogue, paged",
        "description": "Every product, with the whole product on each row, so a script pulling stock into another\nsite needs no second call per row.\n",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PerPage"
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of the catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Product"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/InvalidParameter"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "products.view"
      }
    },
    "/products/{id}": {
      "get": {
        "tags": [
          "Catalogue"
        ],
        "operationId": "getProduct",
        "summary": "One product",
        "description": "One product, in the same shape as a row of the list.",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The product id.",
            "schema": {
              "type": "integer",
              "example": 7
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The product.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Product"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "products.view"
      }
    },
    "/orders": {
      "get": {
        "tags": [
          "Orders"
        ],
        "operationId": "listOrders",
        "summary": "The orders, paged",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PerPage"
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only orders in this state.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "paid",
                "cancelled"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of orders, each carrying its lines, the choice on each line, the shipping it was charged and its totals.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Order"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/InvalidParameter"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "orders.view"
      }
    },
    "/orders/{reference}": {
      "get": {
        "tags": [
          "Orders"
        ],
        "operationId": "getOrder",
        "summary": "One order",
        "description": "One order, addressed by its reference: the string both sides say out loud. The option label\non a line and the shipping rate label were copied onto the order when it was placed, not\nread from the live catalogue, so an order read back always says what was agreed.\n",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "parameters": [
          {
            "name": "reference",
            "in": "path",
            "required": true,
            "description": "The order reference, such as `ORD-261005-W2VVBJ`.",
            "schema": {
              "type": "string",
              "example": "ORD-261005-W2VVBJ"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Order"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "orders.view"
      }
    },
    "/subscriptions": {
      "get": {
        "tags": [
          "Subscriptions"
        ],
        "operationId": "listSubscriptions",
        "summary": "The subscribers and their state, paged",
        "description": "There is no by id read. Read the list and filter by state.",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PerPage"
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only subscriptions in this state.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "paused",
                "cancelled",
                "failed"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of subscriptions.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ListEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Subscription"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/InvalidParameter"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "subscriptions.view"
      }
    },
    "/shipping": {
      "get": {
        "tags": [
          "Shipping"
        ],
        "operationId": "getShipping",
        "summary": "The whole shipping setup",
        "description": "The zones, the rates inside each zone, the bands of a table rate, the class overrides and\nthe handling classes. This read never installs a default zone or rate: a shop that has set\nnothing is answered honestly with nothing, because a read only endpoint must not write.\n",
        "security": [
          {
            "bearerKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The shipping setup.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ShippingSetup"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorised"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-ability": "shipping.view"
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "The shop's key, sent whole: `Authorization: Bearer ks_live_<prefix>_<secret>`. Do not also\nsend `X-Shop-Id`: a key decides its own shop, and a header naming a different one is\nrefused with `400` rather than quietly answered. A portal session token is refused here.\n"
      }
    },
    "parameters": {
      "PerPage": {
        "name": "per_page",
        "in": "query",
        "required": false,
        "description": "How many rows per page. Default 20, capped at 100. Asking for more than the cap is answered with the cap rather than refused.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 20,
          "maximum": 100
        }
      },
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "description": "Which page to read, counting from 1.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "UpdatedSince": {
        "name": "updated_since",
        "in": "query",
        "required": false,
        "description": "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.",
        "schema": {
          "type": "string",
          "format": "date-time",
          "example": "2026-10-01T00:00:00+08:00"
        }
      }
    },
    "schemas": {
      "ListEnvelope": {
        "type": "object",
        "required": [
          "meta"
        ],
        "properties": {
          "meta": {
            "$ref": "#/components/schemas/PageMeta"
          }
        }
      },
      "PageMeta": {
        "type": "object",
        "required": [
          "current_page",
          "per_page",
          "last_page",
          "total"
        ],
        "properties": {
          "current_page": {
            "type": "integer",
            "example": 1
          },
          "per_page": {
            "type": "integer",
            "example": 20
          },
          "last_page": {
            "type": "integer",
            "example": 3
          },
          "total": {
            "type": "integer",
            "example": 47
          }
        }
      },
      "Shop": {
        "type": "object",
        "required": [
          "name",
          "slug",
          "domain",
          "currency",
          "timezone",
          "created_at"
        ],
        "properties": {
          "name": {
            "type": "string",
            "example": "New Store"
          },
          "slug": {
            "type": "string",
            "example": "demo-new"
          },
          "domain": {
            "type": "string",
            "description": "The host the storefront answers on, custom domain or platform subdomain.",
            "example": "demo-new.kedai.store"
          },
          "currency": {
            "type": "string",
            "example": "MYR"
          },
          "timezone": {
            "type": "string",
            "example": "Asia/Kuala_Lumpur"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "Product": {
        "type": "object",
        "required": [
          "id",
          "name",
          "slug",
          "description",
          "active",
          "kind",
          "fulfilment",
          "billing",
          "requires_shipping",
          "price_minor",
          "price",
          "compare_at_minor",
          "compare_at",
          "on_sale",
          "stock",
          "weight_grams",
          "weight",
          "handling_class",
          "external_url",
          "external_button_label",
          "options",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "example": 7
          },
          "name": {
            "type": "string",
            "example": "Kemeja Linen"
          },
          "slug": {
            "type": "string",
            "example": "kemeja-linen"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "active": {
            "type": "boolean"
          },
          "kind": {
            "type": "string",
            "description": "The shape of the product.",
            "enum": [
              "simple",
              "variable",
              "grouped",
              "bundle",
              "chained"
            ]
          },
          "fulfilment": {
            "type": "string",
            "description": "How it is delivered.",
            "enum": [
              "ship",
              "virtual",
              "download",
              "external",
              "booking"
            ]
          },
          "billing": {
            "type": "string",
            "description": "Whether it is charged once or repeatedly.",
            "enum": [
              "once",
              "recurring"
            ]
          },
          "billing_interval": {
            "type": "string",
            "nullable": true,
            "description": "Only when billing is `recurring`.",
            "enum": [
              "day",
              "week",
              "month",
              "year",
              null
            ]
          },
          "billing_interval_count": {
            "type": "integer",
            "nullable": true,
            "description": "Only when billing is `recurring`. Every how many intervals."
          },
          "requires_shipping": {
            "type": "boolean",
            "description": "Derived from fulfilment, never stored, so it cannot disagree with it."
          },
          "price_minor": {
            "type": "integer",
            "nullable": true,
            "description": "The price in sen. Null means no price was set, which is not a free item."
          },
          "price": {
            "type": "string",
            "nullable": true,
            "description": "The same amount formatted for display. Null for an external listing.",
            "example": "RM 39.00"
          },
          "compare_at_minor": {
            "type": "integer",
            "nullable": true,
            "description": "The older price in sen, when the shop has reduced the product. Null means no reduction, which is not the same as an older price of zero. The price the shopper pays is `price_minor`, so nothing here is ever charged."
          },
          "compare_at": {
            "type": "string",
            "nullable": true,
            "description": "The older price formatted for a screen, crossed out beside `price`. Null when there is no reduction to draw, or when the shop switched the crossing out off.",
            "example": "RM 99.00"
          },
          "on_sale": {
            "type": "boolean",
            "description": "Whether `compare_at_minor` is above `price_minor`. Derived, never stored, so it cannot disagree with the two numbers beside it. Ask this rather than comparing them yourself."
          },
          "stock": {
            "type": "integer",
            "nullable": true,
            "description": "How many are on hand when the product is bought as its own line. Null means the shop does not count it, which is not the same as none left: a product nobody counts sells in any quantity. A product whose choices carry the counts reports null here and the counts on its options instead."
          },
          "weight_grams": {
            "type": "integer",
            "nullable": true,
            "description": "Null means nothing was ever weighed, which is not zero grams."
          },
          "weight": {
            "type": "string",
            "nullable": true,
            "example": "250 g"
          },
          "handling_class": {
            "$ref": "#/components/schemas/HandlingClass"
          },
          "external_url": {
            "type": "string",
            "nullable": true,
            "description": "Only meaningful for an external listing."
          },
          "external_button_label": {
            "type": "string",
            "nullable": true,
            "description": "Only meaningful for an external listing."
          },
          "options": {
            "type": "array",
            "description": "The choices the product offers. Empty for a product with none.",
            "items": {
              "$ref": "#/components/schemas/ProductOption"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "What `updated_since` compares against."
          }
        }
      },
      "HandlingClass": {
        "type": "object",
        "nullable": true,
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string",
            "example": "Bulky"
          }
        }
      },
      "ProductOption": {
        "type": "object",
        "required": [
          "id",
          "group_label",
          "label",
          "price_minor",
          "price",
          "compare_at_minor",
          "compare_at",
          "sku",
          "stock",
          "weight_grams",
          "weight",
          "sort",
          "active"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "group_label": {
            "type": "string",
            "example": "Size"
          },
          "label": {
            "type": "string",
            "example": "Medium"
          },
          "price_minor": {
            "type": "integer",
            "nullable": true,
            "description": "Null means the choice costs what the product costs; it is not a missing price."
          },
          "price": {
            "type": "string",
            "nullable": true
          },
          "compare_at_minor": {
            "type": "integer",
            "nullable": true,
            "description": "A struck through price, when the shop set one."
          },
          "compare_at": {
            "type": "string",
            "nullable": true
          },
          "sku": {
            "type": "string",
            "nullable": true
          },
          "stock": {
            "type": "integer",
            "nullable": true
          },
          "weight_grams": {
            "type": "integer",
            "nullable": true
          },
          "weight": {
            "type": "string",
            "nullable": true
          },
          "sort": {
            "type": "integer"
          },
          "active": {
            "type": "boolean"
          }
        }
      },
      "Order": {
        "type": "object",
        "required": [
          "reference",
          "status",
          "payment_method",
          "payment_method_label",
          "customer",
          "shipping",
          "totals",
          "lines",
          "placed_at",
          "paid_at",
          "cancelled_at",
          "updated_at"
        ],
        "properties": {
          "reference": {
            "type": "string",
            "example": "ORD-261005-W2VVBJ"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "paid",
              "cancelled"
            ]
          },
          "payment_method": {
            "type": "string",
            "nullable": true,
            "enum": [
              "bank_transfer",
              "duitnow",
              "cod",
              "whatsapp",
              "card",
              "subscription",
              null
            ]
          },
          "payment_method_label": {
            "type": "string",
            "nullable": true,
            "example": "DuitNow QR"
          },
          "customer": {
            "$ref": "#/components/schemas/Customer"
          },
          "shipping": {
            "$ref": "#/components/schemas/ShippingCharge"
          },
          "totals": {
            "$ref": "#/components/schemas/Totals"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderLine"
            }
          },
          "placed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "paid_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "What `updated_since` compares against."
          }
        }
      },
      "Customer": {
        "type": "object",
        "required": [
          "name",
          "email",
          "phone"
        ],
        "properties": {
          "name": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ShippingCharge": {
        "type": "object",
        "nullable": true,
        "required": [
          "address",
          "rate_label",
          "zone_id",
          "amount_minor",
          "amount"
        ],
        "description": "The shipping the order was charged, with the rate label as it was copied onto the order.",
        "properties": {
          "address": {
            "type": "string",
            "nullable": true
          },
          "rate_label": {
            "type": "string",
            "nullable": true,
            "example": "Pos laju"
          },
          "zone_id": {
            "type": "integer",
            "nullable": true
          },
          "amount_minor": {
            "type": "integer"
          },
          "amount": {
            "type": "string",
            "example": "RM 8.00"
          }
        }
      },
      "Totals": {
        "type": "object",
        "required": [
          "subtotal_minor",
          "subtotal",
          "shipping_minor",
          "shipping",
          "tax_minor",
          "tax",
          "total_minor",
          "total"
        ],
        "properties": {
          "subtotal_minor": {
            "type": "integer"
          },
          "subtotal": {
            "type": "string"
          },
          "shipping_minor": {
            "type": "integer"
          },
          "shipping": {
            "type": "string"
          },
          "tax_minor": {
            "type": "integer"
          },
          "tax": {
            "type": "string"
          },
          "total_minor": {
            "type": "integer",
            "example": 9700
          },
          "total": {
            "type": "string",
            "example": "RM 97.00"
          }
        }
      },
      "OrderLine": {
        "type": "object",
        "required": [
          "name",
          "option_label",
          "quantity",
          "unit_price_minor",
          "unit_price",
          "line_total_minor",
          "line_total",
          "weight_grams",
          "weight"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "option_label": {
            "type": "string",
            "nullable": true,
            "description": "The choice that was bought, copied onto the line. Null for a product with no choices.",
            "example": "Size: Medium"
          },
          "quantity": {
            "type": "integer"
          },
          "unit_price_minor": {
            "type": "integer"
          },
          "unit_price": {
            "type": "string"
          },
          "line_total_minor": {
            "type": "integer"
          },
          "line_total": {
            "type": "string"
          },
          "weight_grams": {
            "type": "integer",
            "nullable": true
          },
          "weight": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Subscription": {
        "type": "object",
        "required": [
          "id",
          "order_reference",
          "customer_name",
          "customer_email",
          "product_id",
          "product_name",
          "product_missing",
          "quantity",
          "amount_minor",
          "amount",
          "interval",
          "interval_count",
          "cadence",
          "status",
          "state",
          "started_at",
          "next_charge_at",
          "next_charge_label",
          "periods_charged",
          "attempts",
          "last_error",
          "updated_at"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "order_reference": {
            "type": "string",
            "nullable": true,
            "description": "The order that started it."
          },
          "customer_name": {
            "type": "string",
            "nullable": true
          },
          "customer_email": {
            "type": "string"
          },
          "product_id": {
            "type": "integer",
            "nullable": true
          },
          "product_name": {
            "type": "string",
            "description": "Copied at the sale, so a later rename does not rewrite what was agreed."
          },
          "product_missing": {
            "type": "boolean",
            "description": "True when the product behind the subscription has since been removed."
          },
          "quantity": {
            "type": "integer"
          },
          "amount_minor": {
            "type": "integer"
          },
          "amount": {
            "type": "string"
          },
          "interval": {
            "type": "string",
            "enum": [
              "day",
              "week",
              "month",
              "year"
            ]
          },
          "interval_count": {
            "type": "integer"
          },
          "cadence": {
            "type": "string",
            "description": "The cadence in words, decided by the server.",
            "example": "Every month"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "cancelled",
              "failed"
            ]
          },
          "state": {
            "type": "string",
            "description": "The state in words, decided by the server.",
            "example": "Active"
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "next_charge_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Present for every live subscription, and for nothing else."
          },
          "next_charge_label": {
            "type": "string",
            "nullable": true,
            "description": "When it will next charge, in words, written by the server."
          },
          "periods_charged": {
            "type": "integer"
          },
          "attempts": {
            "type": "integer"
          },
          "last_error": {
            "type": "string",
            "nullable": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "What `updated_since` compares against."
          }
        }
      },
      "ShippingSetup": {
        "type": "object",
        "required": [
          "shop",
          "currency",
          "zones",
          "classes",
          "rate_types",
          "table_bases",
          "has_rates"
        ],
        "properties": {
          "shop": {
            "type": "object",
            "required": [
              "name",
              "host"
            ],
            "properties": {
              "name": {
                "type": "string"
              },
              "host": {
                "type": "string"
              }
            }
          },
          "currency": {
            "type": "string",
            "example": "MYR"
          },
          "zones": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ShippingZone"
            }
          },
          "classes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ShippingClass"
            }
          },
          "rate_types": {
            "type": "array",
            "description": "The shapes a rate can take, with the words to show. Sent so a client renders its pickers from what the API knows.",
            "items": {
              "$ref": "#/components/schemas/RateType"
            }
          },
          "table_bases": {
            "type": "array",
            "description": "What a table rate measures the basket in.",
            "items": {
              "$ref": "#/components/schemas/TableBase"
            }
          },
          "has_rates": {
            "type": "boolean",
            "description": "Whether the shop can price a parcel at all. False when every zone has no rate."
          }
        }
      },
      "ShippingZone": {
        "type": "object",
        "required": [
          "id",
          "name",
          "is_default",
          "states",
          "postcode_prefixes",
          "sort",
          "rates"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string",
            "example": "Peninsular"
          },
          "is_default": {
            "type": "boolean",
            "description": "The zone that catches the rest. A shop has one."
          },
          "states": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "Selangor"
            ]
          },
          "postcode_prefixes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sort": {
            "type": "integer"
          },
          "rates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ShippingRate"
            }
          }
        }
      },
      "ShippingRate": {
        "type": "object",
        "required": [
          "id",
          "name",
          "type",
          "amount_minor",
          "free_over_minor",
          "table_basis",
          "sort",
          "bands",
          "class_overrides"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string",
            "example": "Pos laju"
          },
          "type": {
            "type": "string",
            "enum": [
              "flat",
              "free_over",
              "table",
              "pickup"
            ]
          },
          "amount_minor": {
            "type": "integer",
            "nullable": true,
            "description": "Null for a table rate, which is priced from its bands, and for pickup."
          },
          "free_over_minor": {
            "type": "integer",
            "nullable": true,
            "description": "Only for a `free_over` rate."
          },
          "table_basis": {
            "type": "string",
            "nullable": true,
            "enum": [
              "weight",
              "quantity",
              "subtotal",
              null
            ],
            "description": "Only for a `table` rate."
          },
          "sort": {
            "type": "integer"
          },
          "bands": {
            "type": "array",
            "description": "Only for a `table` rate. The bounds are inclusive and either may be null.",
            "items": {
              "$ref": "#/components/schemas/ShippingBand"
            }
          },
          "class_overrides": {
            "type": "array",
            "description": "What this rate charges instead of its own amount when a class is in the basket.",
            "items": {
              "$ref": "#/components/schemas/ClassOverride"
            }
          }
        }
      },
      "ShippingBand": {
        "type": "object",
        "required": [
          "id",
          "min_value",
          "max_value",
          "amount_minor"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "min_value": {
            "type": "integer",
            "nullable": true
          },
          "max_value": {
            "type": "integer",
            "nullable": true
          },
          "amount_minor": {
            "type": "integer"
          }
        }
      },
      "ClassOverride": {
        "type": "object",
        "required": [
          "class_id",
          "amount_minor"
        ],
        "properties": {
          "class_id": {
            "type": "integer"
          },
          "amount_minor": {
            "type": "integer"
          }
        }
      },
      "ShippingClass": {
        "type": "object",
        "required": [
          "id",
          "name",
          "sort",
          "products_count"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string",
            "example": "Bulky"
          },
          "sort": {
            "type": "integer"
          },
          "products_count": {
            "type": "integer",
            "description": "How many products are on it, so removing one is an informed choice."
          }
        }
      },
      "RateType": {
        "type": "object",
        "required": [
          "value",
          "label",
          "hint"
        ],
        "properties": {
          "value": {
            "type": "string",
            "enum": [
              "flat",
              "free_over",
              "table",
              "pickup"
            ]
          },
          "label": {
            "type": "string"
          },
          "hint": {
            "type": "string"
          }
        }
      },
      "TableBase": {
        "type": "object",
        "required": [
          "value",
          "label",
          "unit"
        ],
        "properties": {
          "value": {
            "type": "string",
            "enum": [
              "weight",
              "quantity",
              "subtotal"
            ]
          },
          "label": {
            "type": "string"
          },
          "unit": {
            "type": "string",
            "enum": [
              "grams",
              "items",
              "sen"
            ]
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "description": "Every failure has this shape. `field` is present when one field is to blame.",
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "shop_mismatch",
              "missing_api_key",
              "invalid_api_key",
              "revoked_api_key",
              "expired_api_key",
              "forbidden",
              "not_found",
              "method_not_allowed",
              "invalid_parameter",
              "rate_limited"
            ]
          },
          "message": {
            "type": "string"
          },
          "field": {
            "type": "string",
            "nullable": true,
            "description": "The query parameter or path parameter at fault."
          },
          "permission": {
            "type": "string",
            "nullable": true,
            "description": "On a `forbidden`, the ability the endpoint needed."
          },
          "label": {
            "type": "string",
            "nullable": true,
            "description": "On a `forbidden`, what that ability allows, in words."
          }
        },
        "example": {
          "code": "invalid_parameter",
          "message": "The per page field must be an integer.",
          "field": "per_page"
        }
      }
    },
    "responses": {
      "Unauthorised": {
        "description": "The key is missing, unknown, revoked or expired. A `401` is never a reason to retry: check\nthe key is right, that it has not been revoked and that it has not expired.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "code": "missing_api_key",
                  "message": "This route needs a shop API key. Send it as a bearer token."
                }
              },
              "unknown": {
                "value": {
                  "code": "invalid_api_key",
                  "message": "The shop API key is not recognised. A portal session token will not work here."
                }
              },
              "revoked": {
                "value": {
                  "code": "revoked_api_key",
                  "message": "This shop API key has been revoked."
                }
              },
              "expired": {
                "value": {
                  "code": "expired_api_key",
                  "message": "This shop API key has expired."
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "The key does not carry the ability this endpoint needs. The same refusal a member of the shop without the permission would get.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "code": "forbidden",
              "message": "You do not have access to this action.",
              "permission": "products.view",
              "label": "see products and their numbers"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such row in this shop. A row in a different shop is most often the cause, since a key only ever sees its own.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "code": "not_found",
              "message": "That resource does not exist in this shop.",
              "field": null
            }
          }
        }
      },
      "InvalidParameter": {
        "description": "A query parameter was not valid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "More than 60 requests in a minute for this key. The allowance is counted per key, so one\nbusy key does not spend another's. Wait the seconds in `Retry-After` before sending again.\n",
        "headers": {
          "Retry-After": {
            "description": "The number of seconds to wait before trying again.",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "description": "The ceiling.",
            "schema": {
              "type": "integer",
              "example": 60
            }
          },
          "X-RateLimit-Remaining": {
            "description": "How many requests are left in this window.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "code": "rate_limited",
              "message": "Too many requests. Back off and try again."
            }
          }
        }
      }
    }
  }
}
