{
  "openapi": "3.1.0",
  "info": {
    "title": "FestivosAPI",
    "version": "1.0.0",
    "summary": "Spain public holidays, down to the municipio.",
    "description": "National + all 19 regional (17 CCAA + Ceuta + Melilla) public holidays, plus municipio-level *fiestas patronales*, as one JSON feed. National and regional calendars are 100% complete for 2026; municipio-level local coverage is 100% *accounted for* (7,731 municipios carry real fiesta data, 401 are verified to declare none -- see GET /v1/coverage for the live ledger). Only 2026 is loaded; 2027 data lands once the BOE publishes its resolution, expected autumn 2026.\n\nGet a free key instantly: `POST /v1/signup`, no email required. Paid tiers (Basic 9 EUR/mo, Pro 19 EUR/mo) upgrade that same key via a Stripe Payment Link linked from https://festivos.dsl4.com/.",
    "termsOfService": "https://festivos.dsl4.com/terminos",
    "contact": {
      "name": "FestivosAPI support",
      "email": "api@festivos.dsl4.com",
      "url": "https://festivos.dsl4.com"
    },
    "x-rate-limits": {
      "description": "Daily request cap per API key, counted per UTC day. Configurable in wrangler.toml [vars] without a code change; values below are the live production defaults.",
      "free": 100,
      "basic": 20000,
      "pro": 200000
    }
  },
  "servers": [
    {
      "url": "https://festivos.dsl4.com",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Holidays",
      "description": "The core data: national/regional/local holidays merged per municipio."
    },
    {
      "name": "Reference",
      "description": "Static/reference lookups. No API key required."
    },
    {
      "name": "Account",
      "description": "API key issuance."
    },
    {
      "name": "Billing",
      "description": "Stripe webhook (server-to-server, not a consumer-facing call)."
    }
  ],
  "paths": {
    "/v1/signup": {
      "post": {
        "operationId": "signup",
        "tags": [
          "Account"
        ],
        "summary": "Create a free-tier API key",
        "description": "Unauthenticated, instant, no email required. Rate-limited per source IP (default 20/day, see `SIGNUP_RATE_LIMIT_PER_DAY`) to prevent free-key farming. To move to a paid tier, subscribe via the Basic/Pro Stripe Payment Link on https://festivos.dsl4.com/ appending `?client_reference_id=<this key>` so checkout upgrades this same key in place instead of minting a new one.",
        "security": [],
        "responses": {
          "201": {
            "description": "Key created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignupResponse"
                },
                "example": {
                  "key": "fk_free_1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d",
                  "tier": "free",
                  "daily_limit": 100,
                  "docs": "https://festivos.dsl4.com/"
                }
              }
            }
          },
          "429": {
            "description": "Too many free-key signups from this IP today.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the caller may retry (fixed at 3600).",
                "schema": {
                  "type": "integer",
                  "example": 3600
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "signup_rate_limit_exceeded",
                  "limit": 20,
                  "hint": "too many free keys from this IP today; try again tomorrow or subscribe at https://festivos.dsl4.com/"
                }
              }
            }
          }
        }
      }
    },
    "/v1/ccaa": {
      "get": {
        "operationId": "listCcaa",
        "tags": [
          "Reference"
        ],
        "summary": "List the 19 regions (17 CCAA + Ceuta + Melilla)",
        "description": "No API key required.",
        "security": [],
        "responses": {
          "200": {
            "description": "All regions.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CcaaListResponse"
                },
                "example": {
                  "count": 19,
                  "results": [
                    {
                      "code": "AN",
                      "name": "Andalucía",
                      "iso_3166_2": "ES-AN"
                    },
                    {
                      "code": "AR",
                      "name": "Aragón",
                      "iso_3166_2": "ES-AR"
                    },
                    {
                      "code": "CN",
                      "name": "Canarias",
                      "iso_3166_2": "ES-CN"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/v1/coverage": {
      "get": {
        "operationId": "getCoverage",
        "tags": [
          "Reference"
        ],
        "summary": "Municipio-level local-fiesta coverage, per region",
        "description": "The live, machine-readable version of the honesty table on https://festivos.dsl4.com/. No API key required. `unaccounted` is 0 across the board for 2026; the honest open item is years, not municipios -- 2027 data lands once the BOE publishes next year's resolution (expected autumn 2026).",
        "security": [],
        "responses": {
          "200": {
            "description": "Coverage ledger.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoverageResponse"
                },
                "example": {
                  "total_municipios": 8132,
                  "total_local_covered": 7731,
                  "total_none_declared": 401,
                  "total_accounted": 8132,
                  "unaccounted": 0,
                  "pct": 95.1,
                  "pct_accounted": 100,
                  "note": "national + all 19 regional calendars are complete. For `local` (municipio-level fiestas patronales): `covered` = has fiesta data; `none_declared` = verified to declare no separate municipal fiesta (adopts the provincial calendar / concejo-only valley); `accounted` = covered + none_declared. `unaccounted` municipios are the honest remaining gap -- never claimed as covered.",
                  "by_ccaa": [
                    {
                      "ccaa_code": "CL",
                      "municipios_total": 2248,
                      "municipios_with_local_holidays": 2077,
                      "municipios_none_declared": 171,
                      "status": "accounted",
                      "ccaa_name": "Castilla y León"
                    },
                    {
                      "ccaa_code": "CT",
                      "municipios_total": 947,
                      "municipios_with_local_holidays": 917,
                      "municipios_none_declared": 30,
                      "status": "accounted",
                      "ccaa_name": "Cataluña"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/v1/holidays": {
      "get": {
        "operationId": "getHolidays",
        "tags": [
          "Holidays"
        ],
        "summary": "Every holiday for a municipio in a given year",
        "description": "Merges all four scopes that apply -- nacional, ccaa, isla (if applicable), and local -- into one sorted list.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "ApiKeyQuery": []
          }
        ],
        "parameters": [
          {
            "name": "municipio",
            "in": "query",
            "required": true,
            "description": "5-digit INE code (e.g. `28079` = Madrid) or an exact/unambiguous-prefix municipio name (e.g. `San Cristóbal de La Laguna`).",
            "schema": {
              "type": "string"
            },
            "example": "28079"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the current UTC year. Only 2026 is loaded as of this spec; 2027 lands ~autumn 2026.",
            "schema": {
              "type": "integer",
              "example": 2026
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Holidays for the municipio/year.",
            "headers": {
              "X-RateLimit-Limit": {
                "description": "The caller's tier daily request cap.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests remaining in the current UTC day.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HolidaysResponse"
                },
                "example": {
                  "municipio": {
                    "ine_code": "28079",
                    "name": "Madrid",
                    "ccaa_code": "MD",
                    "island": null
                  },
                  "year": 2026,
                  "holidays": [
                    {
                      "date": "2026-01-01",
                      "name": "Año Nuevo",
                      "scope": "nacional",
                      "legal_basis": "no sustituible",
                      "source_url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2025-21667"
                    },
                    {
                      "date": "2026-01-06",
                      "name": "Epifanía del Señor",
                      "scope": "nacional",
                      "legal_basis": "sustituible no ejercida",
                      "source_url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2025-21667"
                    },
                    {
                      "date": "2026-04-02",
                      "name": "Jueves Santo",
                      "scope": "ccaa",
                      "legal_basis": "autonómica",
                      "source_url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2025-21667"
                    },
                    {
                      "date": "2026-05-15",
                      "name": "Fiesta local",
                      "scope": "local",
                      "legal_basis": "local (open data: Comunidad de Madrid (open data))",
                      "source_url": "https://datos.comunidad.madrid/dataset/f160eb6c-6715-471e-9bc0-38497aae950f/resource/db6a3cb0-5504-4db8-9fe7-e42af1ae329b/download/festivos_locales.json"
                    }
                  ],
                  "local_coverage": "complete",
                  "local_fiestas_declared": true,
                  "local_coverage_detail": "179/179 municipios in MD have local-fiesta data loaded"
                }
              }
            }
          },
          "400": {
            "description": "Missing or malformed query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingMunicipio": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing ?municipio="
                    }
                  },
                  "malformedDate": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing or malformed ?date= (expected YYYY-MM-DD)"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingKey": {
                    "value": {
                      "error": "missing_api_key",
                      "hint": "pass X-API-Key header or ?api_key=, or POST /v1/signup for a free key"
                    }
                  },
                  "invalidKey": {
                    "value": {
                      "error": "invalid_api_key"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "The municipio param didn't resolve to a known municipio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "municipioNotFound": {
                    "value": {
                      "error": "municipio_not_found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Daily request cap exceeded for this key's tier. Resets at midnight UTC.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the caller may retry (fixed at 3600).",
                "schema": {
                  "type": "integer",
                  "example": 3600
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rateLimited": {
                    "value": {
                      "error": "rate_limit_exceeded",
                      "limit": 100,
                      "tier": "free"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/is-holiday": {
      "get": {
        "operationId": "isHoliday",
        "tags": [
          "Holidays"
        ],
        "summary": "Is this exact date a holiday for this municipio?",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "ApiKeyQuery": []
          }
        ],
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "required": true,
            "description": "YYYY-MM-DD.",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-05-15"
          },
          {
            "name": "municipio",
            "in": "query",
            "required": true,
            "description": "5-digit INE code (e.g. `28079` = Madrid) or an exact/unambiguous-prefix municipio name (e.g. `San Cristóbal de La Laguna`).",
            "schema": {
              "type": "string"
            },
            "example": "28079"
          }
        ],
        "responses": {
          "200": {
            "description": "Match result.",
            "headers": {
              "X-RateLimit-Limit": {
                "description": "The caller's tier daily request cap.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests remaining in the current UTC day.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IsHolidayResponse"
                },
                "example": {
                  "municipio": {
                    "ine_code": "28079",
                    "name": "Madrid"
                  },
                  "date": "2026-05-15",
                  "is_holiday": true,
                  "matches": [
                    {
                      "date": "2026-05-15",
                      "name": "Fiesta local",
                      "scope": "local",
                      "legal_basis": "local (open data: Comunidad de Madrid (open data))",
                      "source_url": "https://datos.comunidad.madrid/dataset/f160eb6c-6715-471e-9bc0-38497aae950f/resource/db6a3cb0-5504-4db8-9fe7-e42af1ae329b/download/festivos_locales.json"
                    }
                  ],
                  "local_coverage": "complete",
                  "local_coverage_detail": "179/179 municipios in MD have local-fiesta data loaded"
                }
              }
            }
          },
          "400": {
            "description": "Missing or malformed query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingMunicipio": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing ?municipio="
                    }
                  },
                  "malformedDate": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing or malformed ?date= (expected YYYY-MM-DD)"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingKey": {
                    "value": {
                      "error": "missing_api_key",
                      "hint": "pass X-API-Key header or ?api_key=, or POST /v1/signup for a free key"
                    }
                  },
                  "invalidKey": {
                    "value": {
                      "error": "invalid_api_key"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "The municipio param didn't resolve to a known municipio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "municipioNotFound": {
                    "value": {
                      "error": "municipio_not_found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Daily request cap exceeded for this key's tier. Resets at midnight UTC.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the caller may retry (fixed at 3600).",
                "schema": {
                  "type": "integer",
                  "example": 3600
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rateLimited": {
                    "value": {
                      "error": "rate_limit_exceeded",
                      "limit": 100,
                      "tier": "free"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/next": {
      "get": {
        "operationId": "nextHoliday",
        "tags": [
          "Holidays"
        ],
        "summary": "The next upcoming holiday for a municipio",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "ApiKeyQuery": []
          }
        ],
        "parameters": [
          {
            "name": "municipio",
            "in": "query",
            "required": true,
            "description": "5-digit INE code (e.g. `28079` = Madrid) or an exact/unambiguous-prefix municipio name (e.g. `San Cristóbal de La Laguna`).",
            "schema": {
              "type": "string"
            },
            "example": "28079"
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "YYYY-MM-DD. Defaults to today (UTC).",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The next holiday on or after `from`.",
            "headers": {
              "X-RateLimit-Limit": {
                "description": "The caller's tier daily request cap.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests remaining in the current UTC day.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NextHolidayResponse"
                },
                "example": {
                  "municipio": {
                    "ine_code": "35016",
                    "name": "Palmas de Gran Canaria, Las"
                  },
                  "from": "2026-07-12",
                  "next": {
                    "date": "2026-08-15",
                    "name": "Asunción de la Virgen",
                    "scope": "nacional",
                    "legal_basis": "no sustituible",
                    "source_url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2025-21667"
                  },
                  "local_coverage": "complete",
                  "local_coverage_detail": "88/88 municipios in CN have local-fiesta data loaded"
                }
              }
            }
          },
          "400": {
            "description": "Missing or malformed query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingMunicipio": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing ?municipio="
                    }
                  },
                  "malformedDate": {
                    "value": {
                      "error": "bad_request",
                      "message": "missing or malformed ?date= (expected YYYY-MM-DD)"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingKey": {
                    "value": {
                      "error": "missing_api_key",
                      "hint": "pass X-API-Key header or ?api_key=, or POST /v1/signup for a free key"
                    }
                  },
                  "invalidKey": {
                    "value": {
                      "error": "invalid_api_key"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "The municipio param didn't resolve to a known municipio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "municipioNotFound": {
                    "value": {
                      "error": "municipio_not_found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Daily request cap exceeded for this key's tier. Resets at midnight UTC.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the caller may retry (fixed at 3600).",
                "schema": {
                  "type": "integer",
                  "example": 3600
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rateLimited": {
                    "value": {
                      "error": "rate_limit_exceeded",
                      "limit": 100,
                      "tier": "free"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/municipios": {
      "get": {
        "operationId": "searchMunicipios",
        "tags": [
          "Reference"
        ],
        "summary": "Search the 8,000+ municipio registry",
        "description": "Use this to resolve a name to an INE code once, then cache the code -- name matching does a full-table LIKE scan.",
        "security": [
          {
            "ApiKeyHeader": []
          },
          {
            "ApiKeyQuery": []
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Substring match on name.",
            "schema": {
              "type": "string"
            },
            "example": "San Cristóbal"
          },
          {
            "name": "ccaa",
            "in": "query",
            "required": false,
            "description": "Filter by 2-letter CCAA code.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "province",
            "in": "query",
            "required": false,
            "description": "Filter by province code.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1-100, default 20.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching municipios.",
            "headers": {
              "X-RateLimit-Limit": {
                "description": "The caller's tier daily request cap.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests remaining in the current UTC day.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MunicipiosSearchResponse"
                },
                "example": {
                  "count": 3,
                  "results": [
                    {
                      "ine_code": "34161",
                      "name": "San Cristóbal de Boedo",
                      "province_code": "34",
                      "ccaa_code": "CL",
                      "island": null
                    },
                    {
                      "ine_code": "40177",
                      "name": "San Cristóbal de Cuéllar",
                      "province_code": "40",
                      "ccaa_code": "CL",
                      "island": null
                    },
                    {
                      "ine_code": "49187",
                      "name": "San Cristóbal de Entreviñas",
                      "province_code": "49",
                      "ccaa_code": "CL",
                      "island": null
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missingKey": {
                    "value": {
                      "error": "missing_api_key",
                      "hint": "pass X-API-Key header or ?api_key=, or POST /v1/signup for a free key"
                    }
                  },
                  "invalidKey": {
                    "value": {
                      "error": "invalid_api_key"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Daily request cap exceeded for this key's tier. Resets at midnight UTC.",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the caller may retry (fixed at 3600).",
                "schema": {
                  "type": "integer",
                  "example": 3600
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "rateLimited": {
                    "value": {
                      "error": "rate_limit_exceeded",
                      "limit": 100,
                      "tier": "free"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "stripeCheckout": {
      "post": {
        "operationId": "stripeWebhook",
        "tags": [
          "Billing"
        ],
        "summary": "Stripe webhook receiver (Stripe calls this, not API consumers)",
        "description": "Documented for completeness -- this is not an endpoint third-party developers call. Configure a Stripe webhook endpoint at `https://festivos.dsl4.com/webhook/stripe` subscribed to exactly `checkout.session.completed` and `customer.subscription.deleted`. Requests must carry a `stripe-signature` header (WebCrypto HMAC-SHA256, 5-minute tolerance) verified against `STRIPE_WEBHOOK_SECRET`; unverified or unconfigured requests are rejected (fail-closed). Events are deduplicated by `event.id` before applying. `checkout.session.completed` upgrades the key referenced by `client_reference_id` (or mints a new key tied to the Stripe customer if none was referenced); `customer.subscription.deleted` downgrades all of that customer's keys back to `free`.",
        "x-stripe-event-types": [
          "checkout.session.completed",
          "customer.subscription.deleted"
        ],
        "parameters": [
          {
            "name": "stripe-signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "A raw Stripe Event object (see Stripe's own API reference -- not redefined here).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Handled (or a benign duplicate).",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "action": "upgraded_existing_key"
                }
              }
            }
          },
          "400": {
            "description": "Bad signature, malformed JSON, or missing event id.",
            "content": {
              "application/json": {
                "examples": {
                  "malformed": {
                    "value": {
                      "error": "malformed_header"
                    }
                  },
                  "mismatch": {
                    "value": {
                      "error": "signature_mismatch"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "STRIPE_WEBHOOK_SECRET is not configured -- fails closed rather than skipping verification.",
            "content": {
              "application/json": {
                "example": {
                  "error": "webhook_not_configured"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Preferred. Get one from POST /v1/signup."
      },
      "ApiKeyQuery": {
        "type": "apiKey",
        "in": "query",
        "name": "api_key",
        "description": "Equivalent to the header; use when headers aren't convenient."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Machine-readable error code, e.g. `invalid_api_key`, `bad_request`, `municipio_not_found`, `rate_limit_exceeded`."
          },
          "message": {
            "type": "string",
            "description": "Human-readable detail. Present on `bad_request`."
          },
          "hint": {
            "type": "string",
            "description": "Actionable next step. Present on `missing_api_key` and `signup_rate_limit_exceeded`."
          },
          "limit": {
            "type": "integer",
            "description": "The tier's daily request cap. Present on `rate_limit_exceeded` and `signup_rate_limit_exceeded`."
          },
          "tier": {
            "type": "string",
            "enum": [
              "free",
              "basic",
              "pro"
            ],
            "description": "Present on `rate_limit_exceeded`."
          }
        },
        "additionalProperties": false
      },
      "MunicipioBrief": {
        "type": "object",
        "description": "Minimal municipio identity, returned by /v1/is-holiday and /v1/next.",
        "properties": {
          "ine_code": {
            "type": "string",
            "pattern": "^\\d{5}$",
            "description": "5-digit INE municipio code."
          },
          "name": {
            "type": "string"
          }
        }
      },
      "MunicipioFull": {
        "type": "object",
        "description": "Full municipio identity, returned by /v1/holidays.",
        "properties": {
          "ine_code": {
            "type": "string",
            "pattern": "^\\d{5}$"
          },
          "name": {
            "type": "string"
          },
          "ccaa_code": {
            "type": "string",
            "description": "2-letter CCAA code, see /v1/ccaa."
          },
          "island": {
            "type": [
              "string",
              "null"
            ],
            "description": "Island scope code for archipelago municipios (Canarias/Balears), else null."
          }
        }
      },
      "MunicipioSearchResult": {
        "type": "object",
        "description": "One row from /v1/municipios.",
        "properties": {
          "ine_code": {
            "type": "string",
            "pattern": "^\\d{5}$"
          },
          "name": {
            "type": "string"
          },
          "province_code": {
            "type": "string"
          },
          "ccaa_code": {
            "type": "string"
          },
          "island": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Holiday": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "example": "2026-05-15"
          },
          "name": {
            "type": "string",
            "example": "Fiesta local"
          },
          "scope": {
            "type": "string",
            "enum": [
              "nacional",
              "ccaa",
              "isla",
              "local"
            ]
          },
          "legal_basis": {
            "type": "string",
            "description": "Free-text legal/source basis, e.g. `no sustituible`, `autonómica`, `local (open data: ...)`."
          },
          "source_url": {
            "type": "string",
            "format": "uri",
            "description": "Link to the BOE/gazette/open-data source this row was ingested from."
          }
        }
      },
      "HolidaysResponse": {
        "type": "object",
        "properties": {
          "municipio": {
            "$ref": "#/components/schemas/MunicipioFull"
          },
          "year": {
            "type": "integer",
            "example": 2026
          },
          "holidays": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Holiday"
            }
          },
          "local_coverage": {
            "type": "string",
            "description": "Coverage status of this municipio's CCAA for local (fiesta patronal) data. Observed values: `complete` (every municipio in the CCAA has data), `accounted` (data + verified none-declared = 100%), `partial`, `none`."
          },
          "local_fiestas_declared": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "true = this municipio has real local-fiesta rows; false = verified to declare no separate municipal fiesta; null = unknown/not yet checked."
          },
          "local_coverage_detail": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable `X/Y municipios in <CCAA> have local-fiesta data loaded`, or null."
          }
        }
      },
      "IsHolidayResponse": {
        "type": "object",
        "properties": {
          "municipio": {
            "$ref": "#/components/schemas/MunicipioBrief"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "is_holiday": {
            "type": "boolean"
          },
          "matches": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Holiday"
            },
            "description": "Usually 0 or 1 entries; can be >1 if scopes overlap on the same date."
          },
          "local_coverage": {
            "type": "string"
          },
          "local_coverage_detail": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "NextHolidayResponse": {
        "type": "object",
        "properties": {
          "municipio": {
            "$ref": "#/components/schemas/MunicipioBrief"
          },
          "from": {
            "type": "string",
            "format": "date"
          },
          "next": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Holiday"
              },
              {
                "type": "null"
              }
            ],
            "description": "null only if no holiday is found in `from`'s year or the following year."
          },
          "local_coverage": {
            "type": [
              "string",
              "null"
            ]
          },
          "local_coverage_detail": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "MunicipiosSearchResponse": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MunicipioSearchResult"
            }
          }
        }
      },
      "CcaaItem": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "iso_3166_2": {
            "type": "string",
            "example": "ES-MD"
          }
        }
      },
      "CcaaListResponse": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "example": 19
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CcaaItem"
            }
          }
        }
      },
      "CoverageByCcaa": {
        "type": "object",
        "properties": {
          "ccaa_code": {
            "type": "string"
          },
          "municipios_total": {
            "type": "integer"
          },
          "municipios_with_local_holidays": {
            "type": "integer"
          },
          "municipios_none_declared": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "description": "`complete`, `accounted`, `partial`, or `none`."
          },
          "ccaa_name": {
            "type": "string"
          }
        }
      },
      "CoverageResponse": {
        "type": "object",
        "description": "The honesty ledger: every one of Spain's 8,132 municipios is either `covered` (has real local-fiesta data) or `none_declared` (verified to declare no separate municipal fiesta). `unaccounted` is the honest remaining gap and is 0 as of 2026 for all national/CCAA scopes -- year coverage is 2026 only until the BOE publishes 2027 (~autumn).",
        "properties": {
          "total_municipios": {
            "type": "integer",
            "example": 8132
          },
          "total_local_covered": {
            "type": "integer"
          },
          "total_none_declared": {
            "type": "integer"
          },
          "total_accounted": {
            "type": "integer"
          },
          "unaccounted": {
            "type": "integer",
            "example": 0
          },
          "pct": {
            "type": "number"
          },
          "pct_accounted": {
            "type": "number",
            "example": 100
          },
          "note": {
            "type": "string"
          },
          "by_ccaa": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CoverageByCcaa"
            }
          }
        }
      },
      "SignupResponse": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "pattern": "^fk_free_[0-9a-f]{32}$",
            "example": "fk_free_1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d"
          },
          "tier": {
            "type": "string",
            "enum": [
              "free"
            ]
          },
          "daily_limit": {
            "type": "integer",
            "example": 100
          },
          "docs": {
            "type": "string",
            "format": "uri"
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Missing or malformed query parameter.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missingMunicipio": {
                "value": {
                  "error": "bad_request",
                  "message": "missing ?municipio="
                }
              },
              "malformedDate": {
                "value": {
                  "error": "bad_request",
                  "message": "missing or malformed ?date= (expected YYYY-MM-DD)"
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missingKey": {
                "value": {
                  "error": "missing_api_key",
                  "hint": "pass X-API-Key header or ?api_key=, or POST /v1/signup for a free key"
                }
              },
              "invalidKey": {
                "value": {
                  "error": "invalid_api_key"
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "The municipio param didn't resolve to a known municipio.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "municipioNotFound": {
                "value": {
                  "error": "municipio_not_found"
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Daily request cap exceeded for this key's tier. Resets at midnight UTC.",
        "headers": {
          "Retry-After": {
            "description": "Seconds until the caller may retry (fixed at 3600).",
            "schema": {
              "type": "integer",
              "example": 3600
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rateLimited": {
                "value": {
                  "error": "rate_limit_exceeded",
                  "limit": 100,
                  "tier": "free"
                }
              }
            }
          }
        }
      }
    }
  }
}