{
  "openapi": "3.1.0",
  "info": {
    "title": "Thien Furniture — Public Read-Only API",
    "version": "1.1.0",
    "summary": "Public, read-only, unauthenticated JSON API for tubepthien.vn and thiensg.vn.",
    "description": "Read-only public API exposing only content that is ALREADY PUBLIC on the two websites: legal entity details, published products/categories (tubepthien.vn), published posts/services/projects, and the Owner-approved reference price list.\n\nRules of the contract:\n- GET only. Every other method returns `405` with `Allow: GET`.\n- No authentication, no API keys, no cookies, no write operations.\n- No customer, lead, order, HR or internal data is ever returned; internal identifiers are never exposed.\n- Rate limit: 60 requests per minute per IP address (`429` + `Retry-After` when exceeded).\n- Every response is JSON (`application/json; charset=utf-8`); errors follow the same envelope as successful calls' metadata.\n\nSite differences: `tubepthien.vn` is a kitchen-cabinet specialist and publishes a product catalogue, so `/products` and `/categories` return data there. `thiensg.vn` is a turnkey interior/construction business with no product catalogue, so `/products` and `/categories` return an empty `data` array with an explanatory `meta.note`, and `/products/{slug}` always returns `404`.\n\nThe documents `/openapi.json` (this file) and `/auth.md` are served as static files from the site root; they are not API operations. Human-readable docs live at `/docs/`.\n\nPagination: the list endpoints (`/products`, `/posts`, `/categories`) support BOTH offset-based and cursor-based paging. Offset paging uses `?limit=` and `?offset=`. Cursor paging is the recommended, agent-friendly form: take the opaque `meta.next_cursor` value from one response and pass it back as `?cursor=`; keep going until `meta.has_more` is `false`. When `cursor` is present it takes precedence over `offset`. Every list response carries `meta.next_cursor` (null on the last page), `meta.has_more`, and `meta.total` when the collection is cheaply countable.\n\nRate-limit headers: every response carries the IETF `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` fields so an agent can self-throttle in real time. A `429` additionally carries `Retry-After` (seconds). The legacy `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers are still sent for older clients and are DEPRECATED (see the deprecation policy).\n\nError model: every non-2xx response is JSON and uses exactly one typed error object. `error.code` is a stable machine-readable enum (`bad_request`, `not_found`, `method_not_allowed`, `rate_limited`, `internal_error`), `error.message` is human-readable, and `error.status`, `error.documentation` and `error.request_id` complete the envelope. The same `Error`/`ErrorEnvelope` schema is referenced by EVERY documented 4xx and 5xx response of EVERY operation in this document.\n\nVersioning and deprecation policy: this API is versioned in the URL path; the current stable major version is `v1` (`/api/v1/...`). Backward-compatible additions (new fields, new endpoints, new optional parameters) may be made inside `v1` without notice. Any breaking change requires a new major path (`/api/v2/...`). When a stable version or endpoint is deprecated we publish it at https://tubepthien.vn/deprecation-policy.md at least 90 days in advance, send the RFC 9745 `Deprecation` header on the affected responses, and send the RFC 8594 `Sunset` header carrying the retirement date. Migrations are documented on the same page. No endpoint of `v1` is deprecated today.\n\nMachine-readable discovery: OpenAPI 3.1 contract at /openapi.json · RFC 9727 API catalog at /.well-known/api-catalog · RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource (it states that this resource requires no authorization) · authentication policy at /auth.md · shared error model at /docs/.",
    "contact": {
      "name": "Thien Furniture (CÔNG TY TNHH THIẾT KẾ XÂY DỰNG NỘI THẤT THIÊN)",
      "url": "https://tubepthien.vn/docs/",
      "email": "info@tubepthien.vn"
    },
    "license": {
      "name": "Public website content — all rights reserved by the rights holder",
      "identifier": "LicenseRef-Proprietary"
    },
    "x-discovery": {
      "api-catalog": "https://tubepthien.vn/.well-known/api-catalog",
      "protected-resource-metadata": "https://tubepthien.vn/.well-known/oauth-protected-resource",
      "auth": "https://tubepthien.vn/auth.md",
      "docs": "https://tubepthien.vn/docs/",
      "deprecation-policy": "https://tubepthien.vn/deprecation-policy.md"
    }
  },
  "externalDocs": {
    "description": "Developer & agent resources (authentication policy, rate limits, pagination, error model, versioning & deprecation policy, endpoint index)",
    "url": "https://tubepthien.vn/docs/"
  },
  "servers": [
    {
      "url": "https://tubepthien.vn/api/v1",
      "description": "tubepthien.vn (Tủ Bếp Thiên) — kitchen cabinets; publishes a product catalogue."
    },
    {
      "url": "https://thiensg.vn/api/v1",
      "description": "thiensg.vn (THIÊN SG) — turnkey interiors/construction; no product catalogue (see info.description)."
    }
  ],
  "security": [],
  "tags": [
    {
      "name": "meta",
      "description": "API index and self-description."
    },
    {
      "name": "company",
      "description": "Public legal-entity / contact information."
    },
    {
      "name": "catalog",
      "description": "Public product catalogue (tubepthien.vn)."
    },
    {
      "name": "content",
      "description": "Published articles, services and projects."
    },
    {
      "name": "pricing",
      "description": "Owner-approved reference price list (mirrors /pricing.md)."
    }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": [
          "meta"
        ],
        "operationId": "get_api_index",
        "summary": "Self-describing API index",
        "description": "Returns the API name, version, the list of endpoints with their URLs, the link to the OpenAPI document, the authentication policy and the rate-limit policy.",
        "responses": {
          "200": {
            "description": "API index.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=3600"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                },
                "description": "Requests allowed per minute (60)."
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                },
                "description": "Requests remaining in the current window."
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                },
                "description": "Unix epoch when the current window resets."
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiIndexEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        },
        "parameters": []
      }
    },
    "/company": {
      "get": {
        "tags": [
          "company"
        ],
        "operationId": "get_company",
        "summary": "Public legal entity and contact information",
        "description": "Legal name, tax ID, hotline, public email, addresses and official social profiles, exactly as published on the website (footer, /ve-thien-furniture.html, JSON-LD). Served from a static array; no database query. Fields that cannot be verified against the live public pages are omitted rather than guessed.",
        "responses": {
          "200": {
            "description": "Public company information.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=3600"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CompanyEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        },
        "parameters": []
      }
    },
    "/products": {
      "get": {
        "tags": [
          "catalog"
        ],
        "operationId": "list_products",
        "summary": "List published products",
        "description": "Paginated list of products that are published on the website (status = visible). On thiensg.vn this endpoint returns an empty `data` array with `meta.note` because that site publishes no product catalogue. Only whitelisted, publicly displayed fields are returned; internal identifiers, stock levels and SEO control flags are never exposed.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          },
          {
            "$ref": "#/components/parameters/Category"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of published products (possibly empty).",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=300"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProductListEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        }
      }
    },
    "/products/{slug}": {
      "get": {
        "tags": [
          "catalog"
        ],
        "operationId": "get_product",
        "summary": "Get one product by its public slug",
        "description": "Returns one published product identified by the slug that already appears in its public URL (`https://tubepthien.vn/san-pham/{slug}`). Always returns `404` on thiensg.vn, which has no product catalogue.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "description": "The product.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=600"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProductDetailEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        }
      }
    },
    "/categories": {
      "get": {
        "tags": [
          "catalog"
        ],
        "operationId": "list_categories",
        "summary": "List published product categories",
        "description": "Published product categories with their parent slug. Returns an empty `data` array with `meta.note` on thiensg.vn (no product catalogue).",
        "responses": {
          "200": {
            "description": "Published product categories (possibly empty).",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=600"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CategoryListEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ]
      }
    },
    "/posts": {
      "get": {
        "tags": [
          "content"
        ],
        "operationId": "list_posts",
        "summary": "List published articles, services and projects",
        "description": "Paginated list of content already published on the website (articles, services, projects, introduction pages). Titles, slugs, short excerpts, thumbnail, public URL and dates only — never the full stored HTML body.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of published posts.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=300"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostListEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        }
      }
    },
    "/pricing": {
      "get": {
        "tags": [
          "pricing"
        ],
        "operationId": "get_pricing",
        "summary": "Owner-approved reference price list",
        "description": "Machine-readable version of the figures published in `/pricing.md`, which the Owner signed off (effective 2026-10-08). Prices are indicative and are only confirmed in writing after an on-site measurement. Items that the Owner decided not to publish are returned with `published: false` and no numbers.",
        "responses": {
          "200": {
            "description": "Reference price list.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "public, max-age=3600"
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PricingEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (bad limit/offset value or malformed slug/category).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request — HTTP 400: Invalid query parameter (limit/offset/cursor/category/slug).",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                        "status": 400,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_1a2b3c4d"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "404": {
            "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found — HTTP 404: Unknown slug, or a path that matches no documented endpoint.",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "Product not found",
                        "status": 404,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_5e6f7a8b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "405": {
            "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
            "headers": {
              "Allow": {
                "schema": {
                  "type": "string"
                },
                "description": "Always exactly `GET`."
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "method_not_allowed": {
                    "summary": "method_not_allowed — HTTP 405: Only GET is accepted; the response carries `Allow: GET`.",
                    "value": {
                      "error": {
                        "code": "method_not_allowed",
                        "message": "This API accepts GET only",
                        "status": 405,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_9c0d1e2f"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "429": {
            "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              },
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              },
              "RateLimit-Limit": {
                "description": "Requests allowed in the current window (60 per minute per IP address). IETF RateLimit field (draft-ietf-httpapi-ratelimit-headers).",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests remaining in the current window. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Seconds until the current window resets. IETF RateLimit field.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The active quota policy, e.g. `\"60\";w=60`. IETF RateLimit field.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited — HTTP 429: More than 60 requests per minute from one IP address; honour `Retry-After`.",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                        "status": 429,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_3a4b5c6d"
                      }
                    }
                  }
                }
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          },
          "500": {
            "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "internal_error": {
                    "summary": "internal_error — HTTP 500: Unexpected server error; the message is fixed and generic.",
                    "value": {
                      "error": {
                        "code": "internal_error",
                        "message": "Internal server error",
                        "status": 500,
                        "documentation": "https://tubepthien.vn/docs/",
                        "request_id": "rl_7e8f9a0b"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string"
                },
                "description": "no-store"
              }
            },
            "x-error-model": "RFC 9457-style envelope: { error: { code, message, status, documentation, request_id } }"
          }
        },
        "parameters": []
      }
    }
  },
  "components": {
    "parameters": {
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Maximum number of items to return. Values above 50 are clamped to 50; values below 1 or non-integers return `400`.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 50,
          "default": 20
        },
        "example": 20
      },
      "Offset": {
        "name": "offset",
        "in": "query",
        "required": false,
        "description": "Number of items to skip. Non-integers or negative values return `400`.",
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "example": 0
      },
      "Category": {
        "name": "category",
        "in": "query",
        "required": false,
        "description": "Filter products by public category slug (as used in /danh-muc/{slug}). Must match ^[a-z0-9-]{1,120}$.",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9-]{1,120}$"
        },
        "example": "tu-bep-chu-l"
      },
      "Slug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "Public product slug, as it appears in the product URL (/san-pham/{slug}).",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9-]{1,120}$"
        },
        "example": "tu-bep-chu-i-dai-4m-tbt132"
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque cursor for the next page, copied verbatim from a previous response's `meta.next_cursor`. Takes precedence over `offset` when present. Malformed cursors return `400`.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{4,512}$"
        },
        "example": "b2Zmc2V0OjIw"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid input (bad limit/offset value or malformed slug/category).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        },
        "headers": {
          "Cache-Control": {
            "schema": {
              "type": "string"
            },
            "description": "no-store"
          }
        }
      },
      "NotFound": {
        "description": "The requested resource does not exist, or the path does not match any documented endpoint.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        },
        "headers": {
          "Cache-Control": {
            "schema": {
              "type": "string"
            },
            "description": "no-store"
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Only GET is supported. The response always carries an `Allow: GET` header and a JSON error body (never an HTML error page).",
        "headers": {
          "Allow": {
            "schema": {
              "type": "string"
            },
            "description": "Always exactly `GET`."
          },
          "Cache-Control": {
            "schema": {
              "type": "string"
            },
            "description": "no-store"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded (more than 60 requests per minute from one IP address).",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            }
          },
          "Cache-Control": {
            "schema": {
              "type": "string"
            },
            "description": "no-store"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error. The message is fixed and generic; no stack trace, file path, SQL or host detail is ever disclosed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        },
        "headers": {
          "Cache-Control": {
            "schema": {
              "type": "string"
            },
            "description": "no-store"
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "One typed error object shared by every documented 4xx and 5xx response. `code` is a stable machine-readable enum agents can branch on; `message` is human-readable and safe to surface to end users. Same shape as an RFC 9457 problem detail, carried in this API's `{ error: ... }` envelope.",
        "required": [
          "code",
          "message",
          "status",
          "documentation",
          "request_id"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "bad_request",
              "not_found",
              "method_not_allowed",
              "rate_limited",
              "internal_error"
            ],
            "description": "Stable error identifier."
          },
          "message": {
            "type": "string",
            "description": "Short human-readable description (e.g. \"Product not found\")."
          },
          "status": {
            "type": "integer",
            "enum": [
              400,
              404,
              405,
              429,
              500
            ],
            "description": "HTTP status code, repeated in the body."
          },
          "documentation": {
            "type": "string",
            "format": "uri",
            "description": "URL of the developer/agent documentation (the site's /docs/ page)."
          },
          "request_id": {
            "type": "string",
            "description": "Opaque per-request identifier for support and log correlation."
          }
        },
        "additionalProperties": false,
        "x-error-examples": {
          "note": "One example envelope per documented error code. Every 4xx/5xx response of every operation also carries its own `examples` entry for its own status code.",
          "examples": [
            {
              "code": "bad_request",
              "http_status": 400,
              "when": "Invalid query parameter (limit/offset/cursor/category/slug).",
              "body": {
                "error": {
                  "code": "bad_request",
                  "message": "Query parameter \"limit\" must be an integer between 1 and 50",
                  "status": 400,
                  "documentation": "https://tubepthien.vn/docs/",
                  "request_id": "rl_1a2b3c4d"
                }
              }
            },
            {
              "code": "not_found",
              "http_status": 404,
              "when": "Unknown slug, or a path that matches no documented endpoint.",
              "body": {
                "error": {
                  "code": "not_found",
                  "message": "Product not found",
                  "status": 404,
                  "documentation": "https://tubepthien.vn/docs/",
                  "request_id": "rl_5e6f7a8b"
                }
              }
            },
            {
              "code": "method_not_allowed",
              "http_status": 405,
              "when": "Only GET is accepted; the response carries `Allow: GET`.",
              "body": {
                "error": {
                  "code": "method_not_allowed",
                  "message": "This API accepts GET only",
                  "status": 405,
                  "documentation": "https://tubepthien.vn/docs/",
                  "request_id": "rl_9c0d1e2f"
                }
              }
            },
            {
              "code": "rate_limited",
              "http_status": 429,
              "when": "More than 60 requests per minute from one IP address; honour `Retry-After`.",
              "body": {
                "error": {
                  "code": "rate_limited",
                  "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
                  "status": 429,
                  "documentation": "https://tubepthien.vn/docs/",
                  "request_id": "rl_3a4b5c6d"
                }
              }
            },
            {
              "code": "internal_error",
              "http_status": 500,
              "when": "Unexpected server error; the message is fixed and generic.",
              "body": {
                "error": {
                  "code": "internal_error",
                  "message": "Internal server error",
                  "status": 500,
                  "documentation": "https://tubepthien.vn/docs/",
                  "request_id": "rl_7e8f9a0b"
                }
              }
            }
          ]
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        },
        "examples": [
          {
            "error": {
              "code": "not_found",
              "message": "Product not found",
              "status": 404,
              "documentation": "https://tubepthien.vn/docs/",
              "request_id": "rl_5e6f7a8b"
            }
          },
          {
            "error": {
              "code": "rate_limited",
              "message": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header.",
              "status": 429,
              "documentation": "https://tubepthien.vn/docs/",
              "request_id": "rl_3a4b5c6d"
            }
          },
          {
            "error": {
              "code": "method_not_allowed",
              "message": "This API accepts GET only",
              "status": 405,
              "documentation": "https://tubepthien.vn/docs/",
              "request_id": "rl_9c0d1e2f"
            }
          },
          {
            "error": {
              "code": "bad_request",
              "message": "Query parameter \"limit\" must be an integer between 1 and 50",
              "status": 400,
              "documentation": "https://tubepthien.vn/docs/",
              "request_id": "rl_1a2b3c4d"
            }
          },
          {
            "error": {
              "code": "internal_error",
              "message": "Internal server error",
              "status": 500,
              "documentation": "https://tubepthien.vn/docs/",
              "request_id": "rl_7e8f9a0b"
            }
          }
        ],
        "description": "Envelope used by EVERY error response (400, 404, 405, 429, 500) of every operation. `error` always references the shared Error schema."
      },
      "PaginationMeta": {
        "type": "object",
        "required": [
          "count",
          "limit",
          "offset",
          "cursor",
          "next_cursor",
          "has_more"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of items in this page."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "description": "Effective page size after clamping."
          },
          "offset": {
            "type": "integer",
            "minimum": 0,
            "description": "Effective offset."
          },
          "total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Total items, when cheaply countable; otherwise null."
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Present when the endpoint is intentionally empty on this site (e.g. thiensg.vn has no product catalogue)."
          },
          "cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "The cursor this page was requested with (null when offset paging was used)."
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque cursor for the next page; pass it back as `?cursor=`. Null when this is the last page."
          },
          "has_more": {
            "type": "boolean",
            "description": "True when another page exists after this one."
          }
        },
        "description": "Pagination metadata. Offset paging (`limit`/`offset`) and cursor paging (`limit`/`cursor`) are both supported; `next_cursor` is the agent-friendly form."
      },
      "Company": {
        "type": "object",
        "description": "Public legal-entity information, identical in substance to what the website already displays.",
        "required": [
          "legal_name",
          "tax_id",
          "hotline",
          "website"
        ],
        "properties": {
          "legal_name": {
            "type": "string",
            "description": "Full legal name, exactly as published (Vietnamese)."
          },
          "tax_id": {
            "type": "string",
            "description": "Vietnamese tax code (Mã số thuế) as published in the legal-entity block."
          },
          "hotline": {
            "type": "string",
            "description": "Public hotline, digits only."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Public contact mailbox."
          },
          "website": {
            "type": "string",
            "format": "uri"
          },
          "sister_brand": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Publicly cross-referenced second site of the same legal entity."
          },
          "addresses": {
            "type": "array",
            "description": "Public addresses only (showroom / workshop).",
            "items": {
              "type": "object",
              "required": [
                "label",
                "full_address"
              ],
              "properties": {
                "label": {
                  "type": "string"
                },
                "full_address": {
                  "type": "string"
                },
                "map_url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uri"
                }
              }
            }
          },
          "facebook": {
            "type": "array",
            "description": "Official Facebook pages linked from the site footer.",
            "items": {
              "type": "string",
              "format": "uri"
            }
          },
          "showrooms": {
            "type": "array",
            "description": "Public showroom addresses exactly as published in the site footer / contact page.",
            "items": {
              "type": "object",
              "required": [
                "label",
                "full_address"
              ],
              "properties": {
                "label": {
                  "type": "string",
                  "description": "Public label, as shown on the site (e.g. Showroom / Xưởng sản xuất)."
                },
                "full_address": {
                  "type": "string",
                  "description": "Address exactly as published on the website."
                },
                "map_url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "uri",
                  "description": "Public map link published next to the address."
                }
              }
            }
          },
          "factory": {
            "type": "object",
            "description": "Public workshop address exactly as published in the site footer / contact page.",
            "required": [
              "label",
              "full_address"
            ],
            "properties": {
              "label": {
                "type": "string",
                "description": "Public label, as shown on the site (e.g. Showroom / Xưởng sản xuất)."
              },
              "full_address": {
                "type": "string",
                "description": "Address exactly as published on the website."
              },
              "map_url": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uri",
                "description": "Public map link published next to the address."
              }
            }
          }
        }
      },
      "Product": {
        "type": "object",
        "description": "A published product, using only fields that are visible on its public page.",
        "required": [
          "slug",
          "name",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Public slug, as used in /san-pham/{slug}."
          },
          "name": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical public product URL."
          },
          "sku": {
            "type": [
              "string",
              "null"
            ]
          },
          "price": {
            "type": [
              "number",
              "null"
            ],
            "description": "Published reference price (VND), as displayed on the product page."
          },
          "price_sale": {
            "type": [
              "number",
              "null"
            ],
            "description": "Published sale price (VND), if shown."
          },
          "currency": {
            "type": "string",
            "enum": [
              "VND"
            ]
          },
          "short_description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plain-text short description (markup stripped)."
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "categories": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "slug": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 UTC."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 UTC."
          }
        }
      },
      "ProductDetail": {
        "description": "A single product with its gallery and full public description.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Product"
          },
          {
            "type": "object",
            "properties": {
              "description_text": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Public description as plain text (tags stripped, truncated to 4000 characters)."
              },
              "images": {
                "type": "array",
                "description": "Public gallery images.",
                "items": {
                  "type": "object",
                  "required": [
                    "url"
                  ],
                  "properties": {
                    "url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "alt": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "Category": {
        "type": "object",
        "required": [
          "slug",
          "name"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Public slug, as used in /danh-muc/{slug}."
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "parent_slug": {
            "type": [
              "string",
              "null"
            ],
            "description": "Slug of the parent category, when published."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical public category URL."
          }
        }
      },
      "Post": {
        "type": "object",
        "required": [
          "slug",
          "title",
          "type",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Public alias, as used in /{alias}.html."
          },
          "title": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "article",
              "service",
              "project",
              "intro"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "excerpt": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plain-text excerpt (markup stripped, truncated to 500 characters)."
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri"
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 UTC."
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO-8601 UTC."
          },
          "project": {
            "type": [
              "object",
              "null"
            ],
            "description": "Additional PUBLIC project details, present only for type=project and only when they are displayed on the public page.",
            "properties": {
              "acreage_m2": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Floor area in square metres."
              },
              "investor": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Project developer/owner, as published."
              },
              "address": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Project location, as published."
              },
              "video_url": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uri",
                "description": "Public YouTube URL, as published."
              }
            }
          }
        }
      },
      "PricingItem": {
        "type": "object",
        "required": [
          "code",
          "label",
          "unit",
          "published"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Stable item code (slug-like)."
          },
          "label": {
            "type": "string",
            "description": "Item name as published in /pricing.md."
          },
          "unit": {
            "type": "string",
            "description": "Unit of measure, e.g. \"mét dài (md)\", \"m²\"."
          },
          "price_min": {
            "type": [
              "number",
              "null"
            ],
            "description": "Lower bound of the reference range in VND; null when not published."
          },
          "price_max": {
            "type": [
              "number",
              "null"
            ],
            "description": "Upper bound of the reference range in VND; null when not published."
          },
          "currency": {
            "type": "string",
            "enum": [
              "VND"
            ]
          },
          "published": {
            "type": "boolean",
            "description": "false when the Owner decided not to publish a figure for this item; no numbers are then returned."
          },
          "note": {
            "type": [
              "string",
              "null"
            ]
          },
          "effective_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Date the Owner signed off the figure."
          }
        }
      },
      "ApiIndexEnvelope": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "object",
            "required": [
              "name",
              "version",
              "endpoints"
            ],
            "properties": {
              "name": {
                "type": "string"
              },
              "version": {
                "type": "string"
              },
              "description": {
                "type": "string"
              },
              "endpoints": {
                "type": "array",
                "items": {
                  "type": "object",
                  "required": [
                    "method",
                    "path",
                    "operationId"
                  ],
                  "properties": {
                    "method": {
                      "type": "string",
                      "enum": [
                        "GET"
                      ]
                    },
                    "path": {
                      "type": "string"
                    },
                    "operationId": {
                      "type": "string"
                    },
                    "description": {
                      "type": "string"
                    }
                  }
                }
              },
              "spec": {
                "type": "string",
                "description": "URL of the OpenAPI 3.1 document (/openapi.json)."
              },
              "auth": {
                "type": "string",
                "description": "URL of the authentication policy (/auth.md)."
              },
              "docs": {
                "type": "string",
                "description": "URL of the human-readable documentation (/docs/)."
              },
              "rate_limit": {
                "type": "string",
                "description": "Human-readable rate-limit policy, e.g. \"60 requests per minute per IP\"."
              },
              "rate_limit_headers": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Names of the IETF RateLimit-* response headers sent on every response."
              },
              "pagination": {
                "type": "string",
                "description": "Pagination conventions: offset and cursor paging."
              },
              "versioning": {
                "type": "string",
                "description": "Versioning and deprecation policy summary (URL path versioning, 90-day notice, RFC 9745/RFC 8594 headers)."
              },
              "error_model": {
                "type": "string",
                "description": "Summary of the shared typed error object used by every 4xx/5xx response."
              },
              "catalog": {
                "type": "string",
                "format": "uri",
                "description": "RFC 9727 API catalog URL (/.well-known/api-catalog)."
              },
              "protected_resource_metadata": {
                "type": "string",
                "format": "uri",
                "description": "RFC 9728 Protected Resource Metadata URL (states that no authorization is required)."
              },
              "deprecation_policy": {
                "type": "string",
                "format": "uri",
                "description": "URL of the versioning and deprecation policy page."
              }
            }
          }
        }
      },
      "ProductListEnvelope": {
        "type": "object",
        "required": [
          "data",
          "meta"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Product"
            }
          },
          "meta": {
            "$ref": "#/components/schemas/PaginationMeta"
          }
        }
      },
      "CategoryListEnvelope": {
        "type": "object",
        "required": [
          "data",
          "meta"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Category"
            }
          },
          "meta": {
            "$ref": "#/components/schemas/PaginationMeta"
          }
        }
      },
      "PostListEnvelope": {
        "type": "object",
        "required": [
          "data",
          "meta"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Post"
            }
          },
          "meta": {
            "$ref": "#/components/schemas/PaginationMeta"
          }
        }
      },
      "PricingEnvelope": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "object",
            "required": [
              "items",
              "source",
              "disclaimer"
            ],
            "properties": {
              "source": {
                "type": "string",
                "description": "Public source document, /pricing.md."
              },
              "effective_date": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date",
                "description": "Date the Owner signed off the figures."
              },
              "currency": {
                "type": "string",
                "enum": [
                  "VND"
                ]
              },
              "disclaimer": {
                "type": "string",
                "description": "Indicative prices; confirmed in writing only after an on-site measurement."
              },
              "hotline": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Public hotline for quotes."
              },
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PricingItem"
                }
              }
            }
          }
        }
      },
      "CompanyEnvelope": {
        "type": "object",
        "description": "Successful response of `GET /company`.",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "$ref": "#/components/schemas/Company"
          }
        }
      },
      "ProductDetailEnvelope": {
        "type": "object",
        "description": "Successful response of `GET /products/{slug}`.",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "$ref": "#/components/schemas/ProductDetail"
          }
        }
      }
    }
  }
}
