{
  "openapi": "3.1.0",
  "info": {
    "title": "Pasar API",
    "version": "1.1.0",
    "summary": "Searchable directory of Southeast Asian and global APIs.",
    "description": "Read-only, authless, CORS-open directory of Southeast Asian APIs (Malaysia, Singapore, Indonesia, Thailand, the Philippines, Vietnam) plus the world's major API economies, covering government open data and hand-verified commercial APIs.\n\nEvery handled /api/* response carries four provenance headers: `x-pasar-catalogue-version` (the catalogue data version, currently `catalogue-75aaa155`, versioned separately from this document), plus `x-pasar-catalogue-source`, `x-pasar-catalogue-origin` and `x-pasar-catalogue-fingerprint`. All four are named in `access-control-expose-headers`, so browser clients can read them too. The 405 from the method guard carries only the version header, because it answers before any catalogue is built. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.\n\nThe government tier is fetched live from data.gov.my, cached for 6 hours at the edge, and falls back first to a cached copy up to 7 days old and then to a bundled snapshot, so the catalogue total moves by a few entries between those paths. `/api/catalogue` reports which path served the response in `source` and `origin`, how old that copy is in `fetched_at`/`age_seconds`, and adds `degraded`/`degraded_reason` when the answer came from the stale cache or the snapshot.",
    "x-catalogue-version": "catalogue-75aaa155"
  },
  "servers": [
    {
      "url": "https://pasarapi.xyz"
    }
  ],
  "paths": {
    "/api": {
      "get": {
        "summary": "Service index",
        "description": "Name, description, entry count, catalogue version and the endpoint list. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "responses": {
          "200": {
            "description": "Service index",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IndexResponse"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/api/catalogue": {
      "get": {
        "summary": "List APIs",
        "description": "Every entry in the catalogue, government and commercial. Pagination is opt-in. Omit `limit` and the entire result set is returned in `apis`, with `limit`, `offset`, `returned` and `next_offset` absent from the response body. Send `limit` and it is clamped into 1..200: anything above 200 is silently reduced to 200, and zero, negative, empty or non-numeric values silently become 1 rather than returning 400. `offset` is clamped to a minimum of 0 and only takes effect when `limit` is also present. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "description": "Catalogue page, or the whole catalogue when `limit` is omitted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatalogueResponse"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/api/search": {
      "get": {
        "summary": "Search APIs",
        "description": "Filter and rank the catalogue. Filters combine with AND; an empty `q` skips ranking and returns the filtered set in catalogue order. Queries for APIs that are known not to exist publicly return a `not_available` explainer instead of silence. Pagination is opt-in. Omit `limit` and the entire result set is returned in `apis`, with `limit`, `offset`, `returned` and `next_offset` absent from the response body. Send `limit` and it is clamped into 1..200: anything above 200 is silently reduced to 200, and zero, negative, empty or non-numeric values silently become 1 rather than returning 400. `offset` is clamped to a minimum of 0 and only takes effect when `limit` is also present. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Free-text query. Expanded through the synonym/intent list before matching, and an exact provider or id match short-circuits to just that entry. Omit for an unranked filtered list."
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact `category` match, e.g. `Payments`. Values come from /api/categories."
          },
          {
            "name": "tier",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact `tier` match: `open` or `commercial`."
          },
          {
            "name": "pricing",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact `pricing` match: `free`, `freemium` or `paid`. Ignored when `free=true` is also sent."
          },
          {
            "name": "auth",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Exact `auth` match: `none`, `apiKey`, `oauth`, `token` or `bearer`. Ignored when `no_auth=true` is also sent."
          },
          {
            "name": "country",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "ISO-ish country code held by the entry, e.g. `MY`, `SG`, `ID`, `TH`, `PH`, `VN`, or `global`."
          },
          {
            "name": "free",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            },
            "description": "Send `free=true` to force `pricing=free`. Only the exact string `true` counts; any other value, including `1`, is treated as absent. Overrides `pricing`."
          },
          {
            "name": "no_auth",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            },
            "description": "Send `no_auth=true` to force `auth=none`. Only the exact string `true` counts. Overrides `auth`."
          },
          {
            "name": "noauth",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true"
              ]
            },
            "description": "Legacy alias for `no_auth`, still honoured. Prefer `no_auth`."
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked search results",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResponse"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/api/apis/{id}": {
      "get": {
        "summary": "Get one API",
        "description": "One entry plus its setup guide, ready-to-run snippets and AI prompt. Ids may contain a slash (`weather/forecast`); either percent-encode it (`/api/apis/weather%2Fforecast`) or pass it as extra path segments (`/api/apis/weather/forecast`) ~ both resolve to the same entry. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Catalogue `id` (not the `slug`)."
          }
        ],
        "responses": {
          "200": {
            "description": "API details, setup guide and snippets",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiDetails"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "404": {
            "description": "No entry with that id. An id the live build misses but the bundled snapshot still knows gets a `reason`/`detail` pair explaining that it now survives in the snapshot only ~ a stable, self-explaining 404 rather than something that reads like a flake.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/api/categories": {
      "get": {
        "summary": "List categories",
        "description": "Every category present in the catalogue with its entry count, ordered by count descending. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "responses": {
          "200": {
            "description": "Category counts",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CategoriesResponse"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/api/freshness": {
      "get": {
        "summary": "Freshness report",
        "description": "Separates real rot (a verification stamp older than the cutoff) from entries that simply carry no stamp: curated rows never verified once, and the government live-fetch tier which is stampless by design. GET and HEAD only. HEAD returns the identical headers with an empty body. OPTIONS returns the CORS preflight. Any other method returns 405 with a JSON error body.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 3650,
              "default": 90
            },
            "description": "Freshness window in days, clamped into 1..3650. Non-numeric values fall back to 90."
          }
        ],
        "responses": {
          "200": {
            "description": "Freshness report",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FreshnessResponse"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              },
              "x-pasar-catalogue-source": {
                "$ref": "#/components/headers/CatalogueSource"
              },
              "x-pasar-catalogue-origin": {
                "$ref": "#/components/headers/CatalogueOrigin"
              },
              "x-pasar-catalogue-fingerprint": {
                "$ref": "#/components/headers/CatalogueFingerprint"
              }
            }
          },
          "405": {
            "description": "Method other than GET, HEAD or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "summary": "This document",
        "description": "Served with `cache-control: no-store`. GET and HEAD only; any other method returns 405.",
        "responses": {
          "200": {
            "description": "This OpenAPI document",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "headers": {
              "x-pasar-catalogue-version": {
                "$ref": "#/components/headers/CatalogueVersion"
              }
            }
          },
          "405": {
            "description": "Method other than GET or HEAD. Carries no provenance headers ~ the guard answers before any catalogue work.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "summary": "Remote MCP JSON-RPC endpoint",
        "description": "Stateless Streamable HTTP MCP server, authless and read-only. Accepts a single JSON-RPC 2.0 message or a batch of up to 10, body capped at 32 KiB. Rate limited to 60 requests per minute per IP. Tools: `search_sea_apis`, `get_sea_api`, `try_sea_api`, `list_api_categories`, `list_api_stacks`, `get_api_stack`, plus the legacy aliases `search_malaysian_apis` and `get_malaysian_api`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "description": "A JSON-RPC 2.0 request object, or an array of them.",
                "oneOf": [
                  {
                    "type": "object"
                  },
                  {
                    "type": "array",
                    "items": {
                      "type": "object"
                    },
                    "maxItems": 10
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response. Notification-only payloads return 202 with an empty body.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object"
                    },
                    {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  ]
                }
              }
            }
          },
          "405": {
            "description": "GET returns a plain-text pointer to this endpoint; every other non-POST method returns an empty 405."
          },
          "429": {
            "description": "Rate limit exceeded. Carries a `retry-after` header and a JSON-RPC error body."
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "CatalogueVersion": {
        "description": "Catalogue DATA version, the same value as `version` in the body. Versioned separately from `info.version`, which is the server version.",
        "required": true,
        "schema": {
          "type": "string"
        },
        "example": "catalogue-75aaa155"
      },
      "CatalogueSource": {
        "description": "Which build answered: `live` when the government tier came from data.gov.my (fresh or within the stale window), `snapshot` when the bundled copy had to stand in. Same value as `source` in the /api/catalogue body.",
        "required": true,
        "schema": {
          "type": "string",
          "enum": [
            "live",
            "snapshot"
          ]
        }
      },
      "CatalogueOrigin": {
        "description": "The precise copy behind `x-pasar-catalogue-source`: `memory` (isolate memo), `edge-cache` (shared 6h cache), `upstream` (fetched from data.gov.my on this request), `stale-cache` (a live copy older than 6h but under 7 days, served because upstream was unavailable) or `bundled-snapshot` (build-time fallback). The last two mean the response is degraded.",
        "required": true,
        "schema": {
          "type": "string",
          "enum": [
            "memory",
            "edge-cache",
            "upstream",
            "stale-cache",
            "bundled-snapshot"
          ]
        }
      },
      "CatalogueFingerprint": {
        "description": "`<source>.<entry count>.<hash of the government id set>`, e.g. `live.1403.e657c8e0`. Changes whenever the catalogue's composition changes. Compare it between pages while walking `next_offset`: if it moves, the list moved under you and the walk should restart.",
        "required": true,
        "schema": {
          "type": "string"
        }
      }
    },
    "parameters": {
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200
        },
        "description": "Page size, clamped into 1..200. There is NO default: omitting it returns every matching entry and suppresses the `limit`, `offset`, `returned` and `next_offset` response fields. Out-of-range and non-numeric values are clamped rather than rejected."
      },
      "Offset": {
        "name": "offset",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "description": "Zero-based index of the first entry returned. Negative and non-numeric values become 0. Only applied when `limit` is also present."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "Error body. `error` is the only field on every error; the rest appear on the variants named below, so read them defensively.",
        "additionalProperties": true,
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable failure, e.g. `no API with id 'fuelprice'` or `method not allowed`."
          },
          "reason": {
            "type": "string",
            "description": "Short tag for WHY, present on the retired-upstream 404 from /api/apis/{id}. Only ever `retired upstream`, and it means precisely `present in the bundled snapshot, absent from the live build` ~ which also catches ids this repo's own government deny list removes, not only ids the publisher withdrew.",
            "enum": [
              "retired upstream"
            ]
          },
          "detail": {
            "type": "string",
            "description": "Long form of `reason`, present alongside it. Names the dataset and explains that it survives in the bundled snapshot only, which is why an older client may have seen it return 200."
          },
          "endpoints": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Valid endpoint list. Present on the catch-all 404 for an unrecognised /api/* path."
          }
        }
      },
      "AuthDetails": {
        "type": "object",
        "description": "Machine-readable shape of the credential this API expects, derived from `auth` and the entry note.",
        "required": [
          "scheme",
          "type",
          "credential"
        ],
        "properties": {
          "scheme": {
            "type": "string",
            "enum": [
              "none",
              "apiKey",
              "http",
              "bearer",
              "oauth2"
            ]
          },
          "type": {
            "type": "string",
            "enum": [
              "none",
              "header",
              "basic",
              "bearer"
            ]
          },
          "credential": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human name for the secret, e.g. `API key`. Null when no auth is needed."
          },
          "header": {
            "type": "string",
            "description": "Header the credential goes in, when the scheme is header-based."
          },
          "prefix": {
            "type": "string",
            "description": "Authorization-header prefix, when the provider uses a non-standard one."
          }
        }
      },
      "TrustSummary": {
        "type": "object",
        "description": "Provenance and integration-difficulty summary. Present on every entry.",
        "required": [
          "level",
          "label",
          "source",
          "sourceType",
          "endpointType",
          "auth",
          "authLabel",
          "caveat"
        ],
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "copy-paste",
              "authenticated",
              "portal",
              "regulated"
            ]
          },
          "label": {
            "type": "string"
          },
          "source": {
            "type": "string"
          },
          "sourceUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical page for the provider or dataset."
          },
          "sourceType": {
            "type": "string"
          },
          "endpointType": {
            "type": "string"
          },
          "auth": {
            "type": "string"
          },
          "authLabel": {
            "type": "string"
          },
          "lastChecked": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date (YYYY-MM-DD) of the recorded provider review, never inferred from health."
          },
          "verification": {
            "type": "object",
            "description": "Review, docs reachability, sample testing and coverage are distinct evidence. Missing evidence remains null.",
            "properties": {
              "state": {
                "type": "string",
                "enum": [
                  "reviewed",
                  "stale",
                  "unreviewed",
                  "source-managed",
                  "invalid"
                ]
              },
              "label": {
                "type": "string"
              },
              "reviewedAt": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Provider review date."
              },
              "docsCheckedAt": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Documentation check date."
              },
              "sampleCheckedAt": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Sample-test date."
              },
              "method": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Verification method; legacy-review has no newly asserted evidence."
              },
              "evidenceUrl": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Recorded evidence URL, not merely the provider URL."
              },
              "coverageThrough": {
                "type": [
                  "string",
                  "number",
                  "null"
                ]
              },
              "requiresReview": {
                "type": "boolean"
              }
            }
          },
          "rateLimit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Published rate limit, when the provider states one."
          },
          "caveat": {
            "type": "string",
            "description": "What still has to be handled before production."
          }
        }
      },
      "SetupSummary": {
        "type": [
          "object",
          "null"
        ],
        "description": "Short form of the setup guide. Null when no guide applies to the entry.",
        "required": [
          "level",
          "label",
          "summary"
        ],
        "properties": {
          "level": {
            "type": "string",
            "enum": [
              "copy-paste",
              "authenticated",
              "portal",
              "regulated"
            ]
          },
          "label": {
            "type": "string"
          },
          "summary": {
            "type": "string"
          }
        }
      },
      "Api": {
        "type": "object",
        "description": "One catalogue entry in its public shape. Every field listed here is always present; the nullable ones carry null rather than being omitted.",
        "required": [
          "id",
          "slug",
          "title",
          "category",
          "group",
          "country",
          "provider",
          "source",
          "sourceUrl",
          "tier",
          "kind",
          "auth",
          "authDetails",
          "copyable",
          "pricing",
          "docs",
          "baseUrl",
          "frequency",
          "coverage",
          "lastVerified",
          "lastChecked",
          "rateLimit",
          "note",
          "trust",
          "setup"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable catalogue id. May contain a slash."
          },
          "slug": {
            "type": "string",
            "description": "URL-safe form of `id` (slashes become hyphens). Used by the site's per-API pages."
          },
          "title": {
            "type": "string"
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Category label, e.g. `Payments`."
          },
          "group": {
            "type": "string",
            "enum": [
              "Build",
              "Government & open data"
            ],
            "description": "Which of the two top-level shelves the category sits on."
          },
          "country": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Country codes the entry serves. `global` for worldwide APIs."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Organisation operating the API."
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the entry came from. Falls back to `provider`."
          },
          "sourceUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical provider or dataset page."
          },
          "tier": {
            "type": "string",
            "enum": [
              "open",
              "commercial"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "dataset",
              "realtime",
              "portal",
              "commercial"
            ]
          },
          "auth": {
            "type": "string",
            "enum": [
              "none",
              "apiKey",
              "oauth",
              "token",
              "bearer"
            ]
          },
          "authDetails": {
            "$ref": "#/components/schemas/AuthDetails"
          },
          "copyable": {
            "type": "boolean",
            "description": "True when the entry needs no auth and is a callable endpoint rather than a portal, i.e. the snippet runs as-is."
          },
          "pricing": {
            "type": "string",
            "enum": [
              "free",
              "freemium",
              "paid"
            ]
          },
          "docs": {
            "type": [
              "string",
              "null"
            ],
            "description": "Documentation URL."
          },
          "baseUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Base URL for calls. Null for portals and for gov datasets addressed through the shared data-catalogue endpoint."
          },
          "frequency": {
            "type": [
              "string",
              "null"
            ],
            "description": "How often the upstream data updates, e.g. `DAILY`, `REALTIME`."
          },
          "coverage": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dataset coverage as `begin-end` years, when both are known."
          },
          "lastVerified": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date a human last confirmed this entry. Null means never verified, which is not the same as stale ~ see /api/freshness."
          },
          "lastChecked": {
            "type": [
              "string",
              "null"
            ],
            "description": "Legacy alias for the provider review date. Automated reachability stays in health.checkedAt."
          },
          "rateLimit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Published rate limit, when the provider states one."
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Caveats worth reading before integrating."
          },
          "trust": {
            "$ref": "#/components/schemas/TrustSummary"
          },
          "setup": {
            "$ref": "#/components/schemas/SetupSummary"
          }
        }
      },
      "ApiDetails": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Api"
          },
          {
            "type": "object",
            "description": "Adds the generated integration material, and the health probe when one has been recorded.",
            "properties": {
              "copyable": {
                "type": "boolean",
                "description": "Repeated by the snippet builder with the same meaning as on Api."
              },
              "curl": {
                "type": "string",
                "description": "Runnable curl for copy-paste entries; a commented pointer to the docs otherwise."
              },
              "python": {
                "type": "string",
                "description": "Python snippet. Present only for copy-paste entries."
              },
              "js": {
                "type": "string",
                "description": "JavaScript snippet. Present only for copy-paste entries."
              },
              "prompt": {
                "type": "string",
                "description": "Ready-to-paste prompt describing the API, its trust profile and its caveats to a coding agent."
              },
              "setup": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Full setup guide, a superset of the Api `setup` summary. This field is overwritten with the long form on this endpoint.",
                "additionalProperties": true
              },
              "health": {
                "type": "object",
                "description": "Latest health-sweep row for this entry. Absent when no sweep has recorded it or the database is unavailable.",
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "ms": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "checkedAt": {
                    "type": "string"
                  }
                }
              }
            }
          }
        ]
      },
      "IndexResponse": {
        "type": "object",
        "required": [
          "name",
          "description",
          "count",
          "version",
          "endpoints",
          "mcp",
          "site"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "count": {
            "type": "integer",
            "description": "Entries in the catalogue."
          },
          "version": {
            "type": "string",
            "description": "Catalogue data version, same value as the x-pasar-catalogue-version header."
          },
          "endpoints": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "mcp": {
            "type": "string"
          },
          "site": {
            "type": "string"
          }
        }
      },
      "CatalogueResponse": {
        "type": "object",
        "description": "The whole catalogue, or one page of it. `count` and `total` are the same number and both report how many entries matched in full. Neither is the size of this page ~ read `apis.length` for that.",
        "required": [
          "updated",
          "version",
          "source",
          "origin",
          "fingerprint",
          "fetched_at",
          "age_seconds",
          "count",
          "total",
          "apis"
        ],
        "properties": {
          "updated": {
            "type": "string",
            "format": "date",
            "description": "The UTC date this response was generated, NOT the date the catalogue last changed. It advances every day whether or not any entry moved ~ use `version` to detect real data changes."
          },
          "version": {
            "type": "string",
            "description": "Catalogue data version. This is the value that changes when the data changes."
          },
          "source": {
            "type": "string",
            "enum": [
              "live",
              "snapshot"
            ],
            "description": "`live` when the government tier came from data.gov.my (this request or a cached copy of one), `snapshot` when the bundled copy had to stand in. The total differs by a few entries between the two. Also sent as the x-pasar-catalogue-source header."
          },
          "origin": {
            "type": "string",
            "enum": [
              "memory",
              "edge-cache",
              "upstream",
              "stale-cache",
              "bundled-snapshot"
            ],
            "description": "Which copy `source` actually came from. `upstream` is a fetch on this request; `memory` and `edge-cache` are copies under 6h old; `stale-cache` is a live copy over 6h but under 7 days, served because upstream was unavailable; `bundled-snapshot` is the build-time fallback. The last two are the degraded paths. Also sent as the x-pasar-catalogue-origin header."
          },
          "fingerprint": {
            "type": "string",
            "description": "`<source>.<entry count>.<hash of the government id set>`, e.g. `live.1403.e657c8e0`. A drift detector, not a checksum: compare it page to page while walking `next_offset` and restart the walk if it moves, because the list itself moved. Also sent as the x-pasar-catalogue-fingerprint header."
          },
          "fetched_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the government tier behind this response was fetched from data.gov.my. Null when the bundled snapshot answered, which has no fetch time."
          },
          "age_seconds": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Age of that government copy in seconds. Null whenever `fetched_at` is null. Zero-ish means it was fetched on this request."
          },
          "degraded": {
            "type": "boolean",
            "const": true,
            "description": "Present, and always true, only when the government tier did NOT come from a fresh upstream or cached copy ~ i.e. `origin` is `stale-cache` or `bundled-snapshot`. Absent entirely on the healthy path, so test for presence rather than for false."
          },
          "degraded_reason": {
            "type": "string",
            "description": "Why the response is degraded, e.g. `upstream HTTP 429`, `network error: ...`, `cooling down after 429 for another 42s`. Present only alongside `degraded`."
          },
          "count": {
            "type": "integer",
            "description": "Grand total of catalogue entries. Identical to `total`, and not the length of `apis`."
          },
          "total": {
            "type": "integer",
            "description": "Grand total of catalogue entries."
          },
          "limit": {
            "type": "integer",
            "description": "Applied page size after clamping. Present only when `limit` was sent."
          },
          "offset": {
            "type": "integer",
            "description": "Applied offset. Present only when `limit` was sent."
          },
          "returned": {
            "type": "integer",
            "description": "Length of `apis` on this page, i.e. the page size actually delivered ~ smaller than `limit` on the last page. This is the number `count`/`total` are NOT. Present only when `limit` was sent."
          },
          "next_offset": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Offset for the next page, or null on the last page. Present only when `limit` was sent."
          },
          "apis": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Api"
            }
          }
        }
      },
      "SearchResponse": {
        "type": "object",
        "description": "Ranked matches plus facet counts over the full match set. `count` and `total` are the same number and both report how many entries matched in full. Neither is the size of this page ~ read `apis.length` for that.",
        "required": [
          "query",
          "count",
          "total",
          "fingerprint",
          "facets",
          "apis"
        ],
        "properties": {
          "query": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `q` that was searched, or null when none was sent."
          },
          "count": {
            "type": "integer",
            "description": "Grand total of matching entries. Identical to `total`, and not the length of `apis`."
          },
          "total": {
            "type": "integer",
            "description": "Grand total of matching entries."
          },
          "fingerprint": {
            "type": "string",
            "description": "Fingerprint of the catalogue build these results were ranked over, in the same `<source>.<entry count>.<hash>` form as /api/catalogue. Compare it page to page while walking `next_offset`: a change means the ranked list moved and the walk should restart. Also sent as the x-pasar-catalogue-fingerprint header. Search does NOT echo `source`, `origin` or the freshness fields ~ read those headers, or /api/catalogue, if you need them."
          },
          "limit": {
            "type": "integer",
            "description": "Applied page size after clamping. Present only when `limit` was sent."
          },
          "offset": {
            "type": "integer",
            "description": "Applied offset. Present only when `limit` was sent."
          },
          "returned": {
            "type": "integer",
            "description": "Length of `apis` on this page ~ smaller than `limit` on the last page, and never the same thing as `count`/`total`. Present only when `limit` was sent."
          },
          "next_offset": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Offset for the next page, or null on the last page. Present only when `limit` was sent."
          },
          "facets": {
            "type": "object",
            "description": "Counts over ALL matches, not just the returned page, so facet totals exceed `apis.length` whenever a `limit` is applied.",
            "required": [
              "category",
              "country",
              "auth",
              "pricing"
            ],
            "properties": {
              "category": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "country": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "auth": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              },
              "pricing": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              }
            }
          },
          "not_available": {
            "type": "array",
            "description": "Present only when the query names an API that has no public form. Explains why and points at real alternatives already in the catalogue. Can appear alongside results.",
            "items": {
              "type": "object",
              "required": [
                "id",
                "thing",
                "why",
                "alternatives"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "thing": {
                  "type": "string",
                  "description": "What was asked for."
                },
                "why": {
                  "type": "string",
                  "description": "Why it does not exist publicly."
                },
                "alternatives": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "description": "Catalogue ids that do solve the problem."
                },
                "note": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "How to get the same outcome another way."
                }
              }
            }
          },
          "apis": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Api"
            }
          }
        }
      },
      "CategoriesResponse": {
        "type": "object",
        "required": [
          "count",
          "categories"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "description": "Number of distinct categories, i.e. the length of `categories`."
          },
          "categories": {
            "type": "array",
            "description": "Ordered by `count` descending.",
            "items": {
              "type": "object",
              "required": [
                "category",
                "count",
                "group"
              ],
              "properties": {
                "category": {
                  "type": "string"
                },
                "count": {
                  "type": "integer"
                },
                "group": {
                  "type": "string",
                  "enum": [
                    "Build",
                    "Government & open data"
                  ]
                }
              }
            }
          }
        }
      },
      "FreshnessResponse": {
        "type": "object",
        "description": "verified + stale + unstamped + self_maintaining always equals total.",
        "required": [
          "version",
          "days",
          "cutoff",
          "total",
          "verified",
          "stale",
          "unstamped",
          "self_maintaining",
          "note",
          "apis",
          "unstamped_apis"
        ],
        "properties": {
          "version": {
            "type": "string"
          },
          "days": {
            "type": "integer",
            "description": "Size of the freshness window in days."
          },
          "cutoff": {
            "type": "string",
            "format": "date",
            "description": "Verification on or after this date counts as fresh."
          },
          "total": {
            "type": "integer",
            "description": "Entries in the catalogue."
          },
          "verified": {
            "type": "integer",
            "description": "Entries whose verification stamp falls inside the window."
          },
          "stale": {
            "type": "integer",
            "description": "Entries whose verification stamp is older than the cutoff. This is the rot count."
          },
          "unstamped": {
            "type": "integer",
            "description": "Curated entries that carry no verification stamp at all and have never been confirmed."
          },
          "self_maintaining": {
            "type": "integer",
            "description": "Government live-fetch datasets, refetched on every request, so they carry no stamp by design and are excluded from the rot count."
          },
          "note": {
            "type": "string",
            "description": "Plain-language legend for the counts above."
          },
          "apis": {
            "type": "array",
            "description": "The stale entries.",
            "items": {
              "$ref": "#/components/schemas/Api"
            }
          },
          "unstamped_apis": {
            "type": "array",
            "description": "The unstamped curated entries. The self-maintaining tier is counted but not listed.",
            "items": {
              "$ref": "#/components/schemas/Api"
            }
          }
        }
      }
    }
  }
}