API REST para Festivos Públicos de España
Integra datos de festivos españoles en tus aplicaciones mediante una API REST limpia y versionada. Festivos nacionales, autonómicos y locales para cada municipio español — buscables por nombre y filtrables por año.
Autenticación
Todos los endpoints requieren una cabecera X-API-Key. Obtén tu clave desde tu perfil de usuario tras suscribirte.
X-API-Key: your_api_key_here
La clave se comprueba contra tu suscripción en cada petición, así que no necesita fecha de caducidad propia: si la suscripción decae, la clave deja de funcionar al instante, y vuelve a funcionar en cuanto se reanuda el pago.
Documentación interactiva
Explora y prueba todos los endpoints directamente desde tu navegador:
- Swagger UI Documentación interactiva completa
- ReDoc Documentación de referencia clara
Swagger UI te permite pegar tu clave con el botón Authorize y lanzar peticiones reales contra tu propia suscripción.
Endpoints
Todas las rutas cuelgan de una única URL base y están versionadas bajo /v1. La API es estrictamente de solo lectura: no hay endpoints POST, PUT, PATCH ni DELETE, y no los habrá dentro de v1.
https://calendariofestivos.es/api
| Método | Ruta | Devuelve |
|---|---|---|
| GET | /v1/communities | Las 17 comunidades autónomas, paginadas. |
| GET | /v1/communities/{code} | Una comunidad autónoma, localizada por su código. |
| GET | /v1/provinces | Las 52 provincias, paginadas. Filtra con ?community_code= |
| GET | /v1/provinces/{code} | Una provincia, localizada por su código. |
| GET | /v1/holidays | Registros de festivos en bruto, paginados y filtrables. Útil para exportaciones masivas. |
| GET | /v1/holidays/search_municipality | Búsqueda aproximada por nombre de municipio. Es el punto de entrada del flujo habitual. |
| GET | /v1/holidays/{id} | Todos los festivos que se aplican al municipio de esa entrada. |
Filtrado y paginación
Estos parámetros de consulta se aplican a /v1/holidays. Todos son opcionales y se combinan con AND.
| Parámetro | Tipo | Significado |
|---|---|---|
| year | int | Conserva solo los festivos que caen en ese año natural. |
| community_code | int | Comunidad autónoma, por code — el mismo código que devuelve y resuelve /v1/communities. Un código inexistente responde 404, no una página vacía. |
| province_code | int | Provincia, por code — el mismo código que devuelve y resuelve /v1/provinces. Un código inexistente responde 404, no una página vacía. |
| municipality | str | Subcadena sin distinguir mayúsculas, comparada contra los cuatro campos de nombre. Es un LIKE simple, no la búsqueda aproximada — para eso usa search_municipality. |
| date_type | int | Restringe a un nivel de la jerarquía, de 0 a 4. Ver la tabla más abajo. |
| skip | int | Desplazamiento para la paginación. Por defecto, 0. |
| limit | int | Tamaño de página. Por defecto 100, con un tope de 500 — si pides más, recibes 500 sin aviso. |
Los endpoints de listado responden con un sobre de paginación; ve aumentando skip hasta reunir total registros:
GET /v1/holidays?year=2026&date_type=0&limit=2
{
"total": 8214,
"skip": 0,
"limit": 2,
"results": [
{
"id": 84970,
"holiday_date": "2026-01-01",
"description": "Año Nuevo",
"description_vernacular": "Urteberri Eguna",
"municipality": "",
"municipality_bis": "",
"municipality_vernacular": "",
"municipality_vernacular_bis": "",
"island": "",
"region": "",
"province_id": null,
"province_code": null,
"autonomous_community_id": 16,
"autonomous_community_code": 16,
"date_type": 0,
"recoverable": false
}
]
}
Ten en cuenta que search_municipality y /v1/holidays/{id} se saltan el sobre: responden con un array JSON pelado.
Ejemplos de código
Los seis ejemplos hacen lo mismo: buscar un municipio por nombre y usar el id de la mejor coincidencia para obtener sus festivos. Están escritos para pegarlos y ejecutarlos — comprueban los códigos de estado, contemplan el caso sin coincidencias y codifican el término de búsqueda, lo que importa en cuanto consultas un nombre con espacios o acentos como Vitoria-Gasteiz.
Sustituye your_api_key_here por la clave de tu perfil y funcionarán tal cual.
La forma canónica de la petición. Necesita curl y jq.
BASE_URL="https://calendariofestivos.es/api"
API_KEY="your_api_key_here"
# Step 1 — search a municipality by name.
# --get plus --data-urlencode builds the query string and escapes it, so
# names with spaces or accents ("Sant Cugat del Vallès") work unchanged.
curl -s --get "$BASE_URL/v1/holidays/search_municipality" \
--data-urlencode "search_municipality=Bilbao" \
--data-urlencode "year=2026" \
-H "X-API-Key: $API_KEY" | jq .
# Step 2 — keep the id of the best match. Results come back sorted by
# trigram_sim, so element 0 is the closest name.
ENTRY_ID=$(curl -s --get "$BASE_URL/v1/holidays/search_municipality" \
--data-urlencode "search_municipality=Bilbao" \
--data-urlencode "year=2026" \
-H "X-API-Key: $API_KEY" | jq -r '.[0].id')
# Step 3 — every holiday that applies to that municipality, sorted by date.
curl -s "$BASE_URL/v1/holidays/$ENTRY_ID" \
-H "X-API-Key: $API_KEY" \
| jq -r '.[] | "\(.holiday_date) \(.description)"'
Python 3.9 o superior. pip install requests
import requests
BASE_URL = "https://calendariofestivos.es/api"
API_KEY = "your_api_key_here"
SCOPES = ["State", "Community", "Island", "Region", "Municipality"]
# A Session attaches the header once and reuses the TCP connection, which
# matters if you are going to look up more than one municipality.
session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})
def search_municipality(name, year):
"""Matches ordered by trigram_sim, best first. May be empty."""
response = session.get(
f"{BASE_URL}/v1/holidays/search_municipality",
# requests percent-encodes params for you, so spaces and
# accents in `name` are safe.
params={"search_municipality": name, "year": year},
timeout=10,
)
response.raise_for_status()
return response.json()
def holidays_for(entry_id):
"""Every holiday applying to the municipality of `entry_id`, by date."""
response = session.get(f"{BASE_URL}/v1/holidays/{entry_id}", timeout=10)
response.raise_for_status()
return response.json()
try:
results = search_municipality("Bilbao", 2026)
except requests.HTTPError as error:
# 401 means the header is missing, the key is unknown, or the
# subscription behind it is no longer active.
raise SystemExit(f"HTTP {error.response.status_code}: {error.response.text}")
if not results:
raise SystemExit("No municipality matched that name for that year.")
best = results[0]
print(f"Found: {best['municipality']} ({best['province_name']}) — id {best['id']}")
for holiday in holidays_for(best["id"]):
scope = SCOPES[holiday["date_type"]]
print(f"{holiday['holiday_date']} {holiday['description']} [{scope}]")
Node 18 o superior, ejecutado como módulo ES — un fichero .mjs, o un package.json que declare el tipo módulo — para que se permita await de nivel superior. Sin dependencias: fetch viene incorporado.
// Run this on your server, not in the browser. The API answers with
// Access-Control-Allow-Origin: *, so a fetch from a web page would in fact
// succeed — and would hand your API key to everyone who visits the page.
const BASE_URL = "https://calendariofestivos.es/api";
const API_KEY = "your_api_key_here";
const SCOPES = ["State", "Community", "Island", "Region", "Municipality"];
async function apiGet(path, params) {
// URLSearchParams percent-encodes for you, so a name with spaces or
// accents survives the round trip untouched.
const query = new URLSearchParams(params ?? {}).toString();
const url = query ? `${BASE_URL}${path}?${query}` : `${BASE_URL}${path}`;
const response = await fetch(url, { headers: { "X-API-Key": API_KEY } });
if (!response.ok) {
// 401 means the header is missing, the key is unknown, or the
// subscription behind it is no longer active.
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response.json();
}
// Step 1 — search. Results arrive sorted by trigram_sim, best match first.
const results = await apiGet("/v1/holidays/search_municipality", {
search_municipality: "Bilbao",
year: 2026,
});
if (results.length === 0) {
throw new Error("No municipality matched that name for that year.");
}
const best = results[0];
console.log(`Found: ${best.municipality} (${best.province_name}) — id ${best.id}`);
// Step 2 — every holiday applying to that municipality, sorted by date.
const holidays = await apiGet(`/v1/holidays/${best.id}`);
for (const holiday of holidays) {
const scope = SCOPES[holiday.date_type];
console.log(`${holiday.holiday_date} ${holiday.description} [${scope}]`);
}
Java 11 o superior para java.net.http, más Jackson para el JSON: com.fasterxml.jackson.core:jackson-databind. Guárdalo como CalendarioExample.java — el nombre del fichero debe coincidir con el de la clase pública.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class CalendarioExample {
private static final String BASE_URL = "https://calendariofestivos.es/api";
private static final String API_KEY = "your_api_key_here";
private static final String[] SCOPES =
{"State", "Community", "Island", "Region", "Municipality"};
private static final HttpClient CLIENT = HttpClient.newHttpClient();
private static final ObjectMapper MAPPER = new ObjectMapper();
/** GET the URL with the API key attached, failing loudly on any non-200. */
private static JsonNode apiGet(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("X-API-Key", API_KEY)
.GET()
.build();
HttpResponse<String> response =
CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
// 401 means the header is missing, the key is unknown, or the
// subscription behind it is no longer active.
throw new RuntimeException(
"HTTP " + response.statusCode() + ": " + response.body());
}
return MAPPER.readTree(response.body());
}
public static void main(String[] args) throws Exception {
// Step 1 — search. URLEncoder keeps spaces and accents from
// breaking the query string.
String term = URLEncoder.encode("Bilbao", StandardCharsets.UTF_8);
JsonNode results = apiGet(BASE_URL
+ "/v1/holidays/search_municipality"
+ "?search_municipality=" + term
+ "&year=2026");
if (results.isEmpty()) {
System.out.println("No municipality matched that name for that year.");
return;
}
// Results arrive sorted by trigram_sim, so index 0 is the best match.
JsonNode best = results.get(0);
int entryId = best.get("id").asInt();
System.out.printf("Found: %s (%s) — id %d%n",
best.get("municipality").asText(),
best.get("province_name").asText(),
entryId);
// Step 2 — every holiday applying to that municipality, sorted by date.
JsonNode holidays = apiGet(BASE_URL + "/v1/holidays/" + entryId);
for (JsonNode holiday : holidays) {
System.out.printf("%s %s [%s]%n",
holiday.get("holiday_date").asText(),
holiday.get("description").asText(),
SCOPES[holiday.get("date_type").asInt()]);
}
}
}
Rust edición 2021. Añade esto a Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::Value;
const BASE_URL: &str = "https://calendariofestivos.es/api";
const API_KEY: &str = "your_api_key_here";
const SCOPES: [&str; 5] = ["State", "Community", "Island", "Region", "Municipality"];
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut headers = HeaderMap::new();
headers.insert("X-API-Key", HeaderValue::from_str(API_KEY)?);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// Step 1 — search. .query() percent-encodes, so a name with spaces
// or accents survives the round trip untouched.
let results: Value = client
.get(format!("{BASE_URL}/v1/holidays/search_municipality"))
.query(&[("search_municipality", "Bilbao"), ("year", "2026")])
.send()
.await?
// 401 means the header is missing, the key is unknown, or the
// subscription behind it is no longer active.
.error_for_status()?
.json()
.await?;
// Results arrive sorted by trigram_sim, so element 0 is the best match.
let best = match results.as_array().and_then(|entries| entries.first()) {
Some(entry) => entry,
None => {
println!("No municipality matched that name for that year.");
return Ok(());
}
};
let entry_id = best["id"].as_i64().unwrap_or_default();
println!(
"Found: {} ({}) — id {}",
best["municipality"].as_str().unwrap_or_default(),
best["province_name"].as_str().unwrap_or_default(),
entry_id
);
// Step 2 — every holiday applying to that municipality, sorted by date.
let holidays: Value = client
.get(format!("{BASE_URL}/v1/holidays/{entry_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
// as_array() yields an Option; into_iter().flatten() walks it without
// borrowing a temporary, and gives an empty run if the body is not a list.
for holiday in holidays.as_array().into_iter().flatten() {
let index = holiday["date_type"].as_u64().unwrap_or_default() as usize;
let scope = SCOPES.get(index).copied().unwrap_or("Other");
println!(
"{} {} [{}]",
holiday["holiday_date"].as_str().unwrap_or_default(),
holiday["description"].as_str().unwrap_or_default(),
scope
);
}
Ok(())
}
C++17, libcurl y la cabecera única de nlohmann/json. Compila con: g++ -std=c++17 example.cpp -lcurl -o example
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <iostream>
#include <stdexcept>
#include <string>
using json = nlohmann::json;
static const std::string BASE_URL = "https://calendariofestivos.es/api";
static const std::string API_KEY = "your_api_key_here";
static const char* SCOPES[] =
{"State", "Community", "Island", "Region", "Municipality"};
static size_t write_cb(void* data, size_t size, size_t nmemb, std::string* out) {
out->append(static_cast<char*>(data), size * nmemb);
return size * nmemb;
}
// Percent-encode a query value so spaces and accents survive the URL.
static std::string url_encode(const std::string& value) {
CURL* curl = curl_easy_init();
char* escaped = curl_easy_escape(curl, value.c_str(), 0);
std::string out = escaped ? escaped : "";
curl_free(escaped);
curl_easy_cleanup(curl);
return out;
}
// GET the URL with the API key attached, throwing on transport or HTTP errors.
static json api_get(const std::string& url) {
CURL* curl = curl_easy_init();
if (!curl) throw std::runtime_error("curl_easy_init failed");
std::string body;
curl_slist* headers =
curl_slist_append(nullptr, ("X-API-Key: " + API_KEY).c_str());
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
CURLcode rc = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (rc != CURLE_OK) throw std::runtime_error(curl_easy_strerror(rc));
// 401 means the header is missing, the key is unknown, or the
// subscription behind it is no longer active.
if (status != 200)
throw std::runtime_error("HTTP " + std::to_string(status) + ": " + body);
return json::parse(body);
}
// province_name and the other name fields are nullable in the schema, and
// nlohmann throws when you .get a string out of a null. Read them defensively.
static std::string str_or(const json& node, const char* key, const char* fallback) {
return node.contains(key) && node[key].is_string()
? node[key].get<std::string>()
: fallback;
}
int main() {
curl_global_init(CURL_GLOBAL_DEFAULT);
// Step 1 — search. Results arrive sorted by trigram_sim, best match first.
json results = api_get(BASE_URL + "/v1/holidays/search_municipality"
+ "?search_municipality=" + url_encode("Bilbao")
+ "&year=2026");
if (results.empty()) {
std::cout << "No municipality matched that name for that year.\n";
curl_global_cleanup();
return 0;
}
const auto& best = results[0];
int entry_id = best["id"].get<int>();
std::cout << "Found: " << str_or(best, "municipality", "?")
<< " (" << str_or(best, "province_name", "?") << ")"
<< " - id " << entry_id << "\n";
// Step 2 — every holiday applying to that municipality, sorted by date.
json holidays = api_get(BASE_URL + "/v1/holidays/" + std::to_string(entry_id));
for (const auto& holiday : holidays) {
int type = holiday["date_type"].get<int>();
const char* scope = (type >= 0 && type <= 4) ? SCOPES[type] : "Other";
std::cout << str_or(holiday, "holiday_date", "?") << " "
<< str_or(holiday, "description", "?") << " ["
<< scope << "]\n";
}
curl_global_cleanup();
return 0;
}
Qué se recibe
Buscar un municipio
Un array pelado, con la mejor coincidencia primero. El único campo que necesitas es id; el resto está para que puedas mostrar al usuario qué municipio se ha encontrado antes de dar la elección por buena.
GET /v1/holidays/search_municipality?search_municipality=Bilbao&year=2026
[
{
"municipality": "BILBAO",
"municipality_bis": "",
"municipality_vernacular": "BILBO",
"municipality_vernacular_bis": "",
"province_name": "Bizkaia",
"province_name_vernacular": "Bizkaia",
"autonomous_community_name": "País Vasco",
"autonomous_community_name_vernacular": "Euskadi",
"region": "",
"island": "",
"trigram_sim": 1.0,
"id": 84970
}
]
Obtener los festivos
También un array pelado, ordenado por fecha, mezclando todos los niveles de la jerarquía. Los registros con municipio vacío se heredan de la provincia o de la comunidad autónoma — aun así se aplican al municipio que has consultado.
GET /v1/holidays/84970
[
{
"id": 84970,
"holiday_date": "2026-01-01",
"description": "Año Nuevo",
"description_vernacular": "Urteberri Eguna",
"municipality": "",
"municipality_bis": "",
"municipality_vernacular": "",
"municipality_vernacular_bis": "",
"island": "",
"region": "",
"province_code": 48,
"autonomous_community_code": 16,
"date_type": 0,
"recoverable": false,
"province": null,
"autonomous_community": null
}
]
Añade ?indent=true para rellenar los objetos province y autonomous_community con los registros completos en vez de dejarlos a null.
Referencia de campos
date_type
De qué nivel de la jerarquía de festivos española procede el registro. Un municipio hereda todo lo que tiene por encima, que es justo lo que /v1/holidays/{id} resuelve por ti.
| 0 | Nacional — se aplica en toda España. |
| 1 | Comunidad autónoma. |
| 2 | Insular — solo Canarias y Baleares. |
| 3 | Comarcal — una comarca o agrupación similar dentro de una provincia. |
| 4 | Municipal — los dos festivos locales que cada municipio elige por su cuenta. |
El resto de los campos
| recoverable | Si la empresa puede pedir que el día se recupere más adelante. La mayoría de los festivos no son recuperables. |
| description_vernacular municipality_vernacular |
El mismo valor en la lengua cooficial de la región — catalán, euskera, gallego o valenciano. Vacío cuando no existe nombre cooficial. |
| municipality_bis | Un matiz sobre el nombre del municipio, usado en localidades cuyo calendario está dividido. Un valor que contenga EXCEPTO recorta una excepción sobre la entrada principal — el endpoint de búsqueda oculta esos registros, pero la cascada de festivos sí los tiene en cuenta. |
| trigram_sim | Cómo de cercana fue la coincidencia, de 0 a 1, donde 1 es un nombre exacto. Solo se devuelven coincidencias por encima de 0,4 y el array ya viene ordenado, así que rara vez necesitarás leerlo. |
| id / code | Dos números distintos, pero solo uno de ellos es una entrada: el code. Todos los endpoints que reciben una comunidad autónoma o una provincia reciben su code — las rutas /v1/communities/{code} y /v1/provinces/{code}, y los filtros community_code y province_code de /v1/holidays. El id es la clave interna, y se devuelve junto al code únicamente para que puedas casarlo con los province_id y autonomous_community_id que aparecen en los festivos. Cuando envíes un número, envía el code. |
Errores
Todos los fallos llegan como JSON con una única clave detail, así que puedes registrar el cuerpo tal cual:
{
"detail": "Invalid API key or inactive subscription."
}
| 401 | Falta la cabecera X-API-Key, la clave es desconocida, o su suscripción no está activa. La suscripción se verifica en cada petición, así que este es también el aspecto que tiene un pago vencido. |
| 404 | El id o el código de la ruta no existe. Ojo: una búsqueda sin resultados no es un 404 — es un 200 con un array vacío. |
| 422 | Un parámetro tiene el tipo equivocado o falta uno obligatorio — un id no numérico, o una llamada a search_municipality sin término de búsqueda. El campo detail indica exactamente qué parámetro ha fallado. |
Conviene saber
- Guarda tu clave en el servidor. La API permite peticiones de origen cruzado, así que llamarla directamente desde una página web sí funciona — y publica tu clave a todos los visitantes.
- La búsqueda filtra por año y usa el actual cuando lo omites. Buscar un año que aún no se ha publicado devuelve un array vacío, no un error.
- Los acentos se normalizan en el servidor, así que Vitoria y Vitòria encuentran la misma localidad — pero el término sigue necesitando codificación porcentual para sobrevivir a la URL.
- Los nombres de municipio se almacenan en mayúsculas, y los festivos se devuelven siempre ordenados por fecha.
- Los datos de festivos cambian una vez al año, cuando cada administración publica su calendario oficial. Cachear el calendario de un municipio por tu parte durante todo un año es perfectamente razonable.
Empezar
Crea una cuenta y suscríbete desde tu perfil para recibir tu clave API. Paga con tarjeta o por domiciliación SEPA — tu clave se genera automáticamente en cuanto se confirma el pago.