# API de Blogenia > Lee los artículos publicados de tus blogs de Blogenia desde tu web o tu app, recibe avisos con webhooks y publica en tu propia API. Versión web: https://blogelia.com/docs Blogenia genera y publica los artículos de tu blog. La API te permite usar esos artículos fuera de Blogenia: mostrarlos en tu propia web con tu diseño, copiarlos a otro sistema o alimentar a un agente de IA. Es una API REST que devuelve JSON. ## Qué puedes hacer - **Leer** tus blogs y sus artículos publicados, en JSON, HTML o Markdown. Ver [Blogs](https://blogelia.com/docs/blogs) y [Artículos](https://blogelia.com/docs/articulos). - **Consultar tu cuenta y tu analítica**: visitas, ranking de artículos, créditos, artículos en cualquier estado, generaciones, piloto automático y entregas, con claves de permisos limitados. Ver [Construir tu dashboard](https://blogelia.com/docs/construir-dashboard). - **Recibir avisos** en tu servidor cada vez que se publica un artículo. Ver [Webhooks](https://blogelia.com/docs/webhooks). - **Publicar en tu propia API**: Blogenia envía cada artículo a la API de tu web. Ver [Publicar en tu propia API](https://blogelia.com/docs/publicar-en-tu-api). ## Lo básico | Concepto | Valor | | --- | --- | | URL base | `https://api.blogelia.com/v1` | | Formato | JSON en UTF-8. Las fechas son ISO 8601 en UTC y los identificadores, 24 caracteres hexadecimales. | | Autenticación | Cabecera `Authorization: Bearer pk_live_…`. Ver [Autenticación](https://blogelia.com/docs/autenticacion). | | Planes | Starter y Pro. El plan Free no tiene API. | | Límite | 60 peticiones por minuto en Starter y 300 en Pro, por clave. Ver [Límites de uso](https://blogelia.com/docs/limites). | | Acceso | Solo lectura (rutas `GET`). Cada clave lleva los permisos que eliges al crearla. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). | > **Nota:** Las claves y los webhooks se crean y se gestionan desde el [panel](https://app.blogelia.com/api), no con la API. ## Referencia OpenAPI La especificación OpenAPI de las rutas que aceptan clave está en [https://api.blogelia.com/docs](https://api.blogelia.com/docs) (Swagger UI) y en formato JSON en [https://api.blogelia.com/docs-json](https://api.blogelia.com/docs-json). ## Siguiente paso Haz tu primera petición en dos minutos con [Primeros pasos](https://blogelia.com/docs/primeros-pasos). --- # Primeros pasos > Crea una clave de API en el panel y haz tu primera petición a la API de Blogenia en dos minutos. Versión web: https://blogelia.com/docs/primeros-pasos Necesitas una cuenta con plan Starter o Pro y al menos un artículo publicado. ## 1. Crea una clave 1. Entra en el [panel](https://app.blogelia.com/api) y abre **API** en el menú. 2. En **Claves**, pulsa **Crear clave** y ponle un nombre que te ayude a reconocerla. 3. Copia la clave. Empieza por `pk_live_` y **solo se muestra una vez**: si la pierdes, crea otra y revoca la anterior. > **Aviso:** Guarda la clave en una variable de entorno de tu servidor. No la pongas en código que se ejecute en el navegador. ## 2. Localiza el ID de tu blog Las rutas piden el identificador del blog (24 caracteres hexadecimales). La pantalla **API** del panel muestra un ejemplo de petición que ya lo lleva puesto. ## 3. Haz tu primera petición Pide los cinco últimos artículos publicados de tu blog: **Listar artículos** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles?limit=5" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles?limit=5', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles", params={"limit": 5}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "url": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina", "excerpt": "Precios orientativos y qué incluye cada partida.", "publishedAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-02T10:30:00.000Z", "category": { "slug": "reformas", "name": "Reformas" }, "coverImage": { "url": "https://cdn.example.com/cover.webp", "alt": "Cocina reformada" }, "readingMinutes": 5, "faqs": [{ "q": "¿Cuánto tarda una reforma?", "a": "Entre dos y cuatro semanas." }] } ], "page": 1, "limit": 5, "total": 12 } ``` Si ves `401`, revisa la clave. Si ves `403` con `plan_required`, tu cuenta está en Free. Ver [Errores](https://blogelia.com/docs/errores). ## Después - Pide un artículo completo: [Artículos](https://blogelia.com/docs/articulos). - Pagina listas largas: [Paginación](https://blogelia.com/docs/paginacion). - Entérate al momento de cada publicación: [Webhooks](https://blogelia.com/docs/webhooks). --- # Recibir el contenido en tu web > Guía paso a paso para llevar los artículos de Blogenia a tu propia web: leer con la API, reaccionar a un webhook o dejar que Blogenia publique en tu API. Versión web: https://blogelia.com/docs/recibir-contenido Hay tres formas de que los artículos de tu blog aparezcan en tu propia web. Elige según quién debe tomar la iniciativa. | Opción | Quién inicia | Úsala cuando… | Necesitas | | --- | --- | --- | --- | | **A. Leer con la API** | Tu web pide los artículos | Quieres mostrar el blog con tu diseño y te basta con refrescarlo cada pocos minutos. | Una clave de API. Starter o Pro. | | **B. Webhook + API** | Blogenia avisa y tu web pide el artículo | Quieres enterarte al momento de cada publicación para guardarlo, avisar o regenerar páginas. | Un webhook, una clave de API y una URL `https` en tu servidor. Starter o Pro. | | **C. Publicar en tu API** | Blogenia envía el artículo a tu API | Tu web ya tiene una API para crear artículos y quieres que Blogenia los publique allí, incluidas las ediciones y los borrados. | Implementar el [contrato](https://blogelia.com/docs/publicar-en-tu-api) en tu servidor. | > **Nota:** Las opciones A y B no se excluyen: lo habitual es combinarlas. Antes de empezar, lee [Seguridad](https://blogelia.com/docs/seguridad). ## A. Leer con la API 1. En el [panel](https://app.blogelia.com/api), abre **API** y pulsa **Crear clave**. Copia la clave: solo se muestra una vez. 2. Guárdala en tu servidor como variable de entorno `BLOGENIA_API_KEY`. Guarda también el ID del blog en `BLOGENIA_BLOG_ID`. 3. Pide los artículos desde el servidor y guárdalos en caché unos minutos, para no gastar tu [límite](https://blogelia.com/docs/limites) en cada visita. 4. Para el contenido de un artículo, pide `GET /articles/{id}` ([Artículos](https://blogelia.com/docs/articulos)). **Lista con caché de 5 minutos** ```js const BASE = 'https://api.blogelia.com/v1'; const TTL_MS = 5 * 60 * 1000; let cache = { at: 0, data: null }; export async function latestArticles() { if (cache.data && Date.now() - cache.at < TTL_MS) return cache.data; const res = await fetch( BASE + '/blogs/' + process.env.BLOGENIA_BLOG_ID + '/articles?limit=10', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY } }, ); if (!res.ok) { if (cache.data) return cache.data; // si falla, sirve lo último que tenías throw new Error('HTTP ' + res.status); } const body = await res.json(); cache = { at: Date.now(), data: body.data }; return cache.data; } ``` ## B. Webhook y lectura del artículo 1. Crea una clave de API como en la opción A. 2. Expón en tu servidor una URL `https` que reciba `POST` (por ejemplo `/webhooks/blogenia`). 3. En el [panel](https://app.blogelia.com/api), en **Webhooks**, pulsa **Añadir webhook**, pega la URL y copia el secreto (`whsec_…`). Solo se muestra una vez. 4. Guarda el secreto como variable de entorno `BLOGENIA_WEBHOOK_SECRET`. 5. Pulsa **Enviar prueba** y comprueba que tu servidor responde `200`. 6. En cada `article.published`, verifica la firma, responde `200` y pide el artículo completo con su `id`. **Al recibir article.published** ```js // isValid(rawBody, headers): ver "Verificar la firma" en Webhooks app.post('/webhooks/blogenia', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); if (!isValid(rawBody, req.headers)) return res.sendStatus(400); res.sendStatus(200); // responde ya; trabaja después const event = JSON.parse(rawBody); if (event.event !== 'article.published') return; const id = event.data.article.id; fetch('https://api.blogelia.com/v1/articles/' + id, { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }) .then((r) => r.json()) .then((article) => saveInYourDatabase(article)) // tu código .catch((err) => console.error(err)); }); ``` El código completo de `isValid` está en [Webhooks](https://blogelia.com/docs/webhooks#firma), con Node, Python y PHP. ## C. Publicar en tu propia API 1. Implementa en tu servidor las rutas del [contrato](https://blogelia.com/docs/publicar-en-tu-api): comprobar, crear, actualizar y borrar. Parte del receptor de ejemplo. 2. Crea una clave secreta para Blogenia y guárdala en tu servidor como variable de entorno `BLOGENIA_TOKEN`. 3. En el [panel](https://app.blogelia.com/blog), abre **Blog** y la pestaña **Publicación**. Elige **API propia**, escribe la dirección de tu web, pega la clave como **Clave de acceso** y guarda. 4. Comprueba la conexión. Si va bien, cada artículo que se publique se enviará a tu API. Para una prueba rápida, arranca el receptor de ejemplo de [Publicar en tu propia API](https://blogelia.com/docs/publicar-en-tu-api#ejemplo) detrás de un túnel `https`. > **Aviso:** En las tres opciones, las claves y secretos viven solo en tu servidor. Consulta la lista de comprobación de [Seguridad](https://blogelia.com/docs/seguridad). --- # Construir tu dashboard > Guía para llevar las visitas y los artículos de Blogenia a tu propio dashboard: qué rutas llamar, cuánto cachear y un ejemplo en Node.js. Versión web: https://blogelia.com/docs/construir-dashboard Con la API puedes mostrar en tu propio panel las visitas de tus blogs, los artículos más leídos y el estado de la cuenta. Esta guía resume qué rutas llamar y cómo hacerlo de forma segura y sin gastar peticiones de más. ## 1. Crea una clave con el permiso justo - Para un dashboard de analítica, crea una clave **solo con `stats:read`**. No da acceso a artículos ni a datos de la cuenta, así que, si se filtra, el daño es mínimo. - Si además necesitas créditos, plan o estado de artículos, crea **otra clave aparte** con `account:read` y úsala solo en el servidor. No mezcles los dos permisos en una clave. - Los permisos se eligen al crear la clave, en [**API** del panel](https://app.blogelia.com/api), y no se pueden ampliar después. Si te falta uno, crea otra clave y revoca la antigua. `account:read` solo puede concederlo el propietario de la cuenta. ## 2. Llama siempre desde tu servidor Las rutas de analítica y de cuenta no admiten CORS, a propósito: el navegador no puede llamarlas. Tu servidor pide los datos y tu front-end los pide a tu servidor. Nunca pongas la clave en código del navegador. ## 3. Qué rutas llamar | Quieres mostrar… | Ruta | Permiso | | --- | --- | --- | | Visitas totales de la cuenta y por blog | [`GET /account/stats`](https://blogelia.com/docs/analitica#cuenta) | `stats:read` | | Visitas de un blog por día, semana o mes | [`GET /blogs/{blogId}/stats`](https://blogelia.com/docs/analitica#blog) | `stats:read` | | Artículos más leídos | [`GET /blogs/{blogId}/stats/articles`](https://blogelia.com/docs/analitica#ranking) | `stats:read` | | Visitas de un artículo | [`GET /articles/{id}/stats`](https://blogelia.com/docs/analitica#articulo) | `stats:read` | | Créditos, plan, uso del mes y blogs | [`GET /account`](https://blogelia.com/docs/cuenta) | `account:read` | | Cuántos artículos hay por estado | [`GET /blogs/{blogId}/articles/summary`](https://blogelia.com/docs/articulos-todos#resumen) | `account:read` | | Qué está en marcha o ha fallado | [`GET /blogs/{blogId}/generations`](https://blogelia.com/docs/generaciones) | `account:read` | | Próximos huecos del piloto | [`GET /blogs/{blogId}/autopilot`](https://blogelia.com/docs/piloto-automatico) | `account:read` | ## 4. Cachea 10 minutos Las estadísticas se actualizan cada 10 minutos, así que pedirlas más a menudo no te da datos nuevos y gasta tu [límite de uso](https://blogelia.com/docs/limites). Guarda la respuesta en tu servidor 10 minutos (en memoria, Redis o donde prefieras) y sirve esa copia a todos tus visitantes. ## 5. Ejemplo en Node.js Sin SDK, solo `fetch` (Node 18 o posterior). Pide el total de la cuenta y los cinco artículos más leídos de un blog, y lo guarda 10 minutos. Si Blogenia responde `429` o falla, sirve el dato anterior. **Módulo blogenia.js** ```js const API = 'https://api.blogelia.com/v1'; const TTL_MS = 10 * 60 * 1000; // los datos se actualizan cada 10 minutos const cache = new Map(); async function blogenia(path, query = {}) { const url = new URL(API + path); for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v)); const res = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_STATS_KEY }, }); const remaining = res.headers.get('X-RateLimit-Remaining'); if (remaining !== null && Number(remaining) < 5) console.warn('Quedan pocas peticiones:', remaining); if (res.status === 429) { const err = new Error('rate_limited'); err.retryAfter = Number(res.headers.get('Retry-After') ?? 60); throw err; } if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); } async function cached(key, load) { const hit = cache.get(key); if (hit && Date.now() - hit.at < TTL_MS) return hit.value; try { const value = await load(); cache.set(key, { at: Date.now(), value }); return value; } catch (err) { if (hit) return hit.value; // ante un 429 o un fallo, sirve el dato anterior throw err; } } export function dashboardData(blogId) { return cached('dashboard:' + blogId, async () => { const [account, top] = await Promise.all([ blogenia('/account/stats', { interval: 'day' }), blogenia('/blogs/' + blogId + '/stats/articles', { limit: 5 }), ]); return { views: account.totals.views, uniquesApproxSum: account.totals.uniquesApproxSum, series: account.series, // el último punto puede llevar partial: true blogs: account.blogs, topArticles: top.data, updatedAt: account.updatedAt, // puede ser null }; }); } ``` **Ruta de tu servidor** ```js import express from 'express'; import { dashboardData } from './blogenia.js'; const app = express(); // Tu navegador llama a TU servidor; solo tu servidor conoce la clave. app.get('/api/dashboard/:blogId', async (req, res) => { try { res.json(await dashboardData(req.params.blogId)); } catch (err) { res.status(err.retryAfter ? 503 : 502).json({ error: 'No hay datos por ahora' }); } }); ``` ## 6. Casos que debes tratar - **`partial: true`.** El último grupo de la serie incluye hoy y aún no ha terminado. Píntalo distinto (por ejemplo, con una línea discontinua) o déjalo fuera de las comparaciones con periodos completos. - **`updatedAt: null`.** Todavía no se conoce la hora del último volcado de estadísticas. No muestres «actualizado a las…»; las cifras siguen siendo válidas. - **`429`.** Superaste el límite del plan. Espera los segundos de `Retry-After` antes de reintentar y, mientras tanto, sirve la copia en caché. Vigila `X-RateLimit-Remaining`. Ver [Límites de uso](https://blogelia.com/docs/limites). - **`403` `insufficient_scope`.** La clave no tiene el permiso de esa ruta. Crea otra con el permiso adecuado. - **Únicos.** `uniquesApprox` es aproximado y `uniquesApproxSum` no es el número de personas distintas del periodo. Etiquétalo como «visitantes (aprox.)». > **Nota:** Todas las rutas de la clave comparten el mismo límite por minuto. Con la caché de 10 minutos, un dashboard necesita solo unas pocas peticiones cada 10 minutos. Más sobre las claves en [Seguridad](https://blogelia.com/docs/seguridad). --- # Autenticación > Cómo autenticarte en la API de Blogenia con una clave pk_live_ en la cabecera Authorization. Versión web: https://blogelia.com/docs/autenticacion Todas las peticiones llevan una clave de API en la cabecera `Authorization`, con el esquema `Bearer`. **Cabecera** ```http Authorization: Bearer pk_live_0123456789abcdef0123456789abcdef ``` ## La clave - Formato: `pk_live_` seguido de 32 caracteres hexadecimales. - Se crea en el [panel](https://app.blogelia.com/api), en **API**. Solo se muestra al crearla. Blogenia guarda únicamente su huella (SHA-256). - Puedes tener hasta 10 claves activas. Al revocar una, deja de funcionar al momento en el proceso que la revoca y, como máximo, 30 segundos después en el resto. - Cada clave pertenece a tu organización y solo ve sus datos. - Las claves son de solo lectura y llevan **permisos** (scopes) que eliges al crearlas. Ver [Permisos](#scopes). ## Permisos (scopes) Cada clave lleva solo los permisos que necesita, para que una clave filtrada dé el menor acceso posible. Se marcan al crear la clave en el [panel](https://app.blogelia.com/api) y, si no marcas ninguno, la clave lleva solo `content:read`. | Permiso | Qué permite | Rutas | | --- | --- | --- | | `content:read` | Leer el contenido publicado. | `GET /blogs/{blogId}`, `GET /blogs/{blogId}/articles` y `GET /articles/{id}`. Ver [Blogs](https://blogelia.com/docs/blogs) y [Artículos](https://blogelia.com/docs/articulos). | | `stats:read` | Leer la analítica de visitas. | `GET /account/stats`, `GET /blogs/{blogId}/stats`, `GET /blogs/{blogId}/stats/articles` y `GET /articles/{id}/stats`. Ver [Analítica](https://blogelia.com/docs/analitica). | | `account:read` | Leer datos internos de la cuenta: créditos, plan, artículos en cualquier estado, generaciones, piloto automático y entregas. | [Cuenta](https://blogelia.com/docs/cuenta), [Artículos (todos los estados)](https://blogelia.com/docs/articulos-todos), [Generaciones](https://blogelia.com/docs/generaciones), [Piloto automático](https://blogelia.com/docs/piloto-automatico) y [Entregas](https://blogelia.com/docs/entregas). | - Las claves anteriores con el permiso `read` cuentan como `content:read`: siguen funcionando igual y no ganan permisos nuevos. - Si la clave no tiene el permiso de la ruta, la respuesta es `403` con `code: "insufficient_scope"` y el permiso que falta en el mensaje. - **Los permisos no se pueden cambiar después de crear la clave.** Para ampliarlos, crea otra clave y revoca la antigua (como al [rotar](https://blogelia.com/docs/seguridad#rotar) una clave). Así una clave filtrada nunca gana permisos. - `account:read` solo puede concederlo el propietario de la cuenta; si no lo eres, la creación responde `403` con `code: "owner_required"`. El panel avisa de que da acceso a datos internos y que no debe usarse en el navegador. - Todos los permisos comparten el [límite de uso](https://blogelia.com/docs/limites) de la clave. - Las rutas de `stats:read` y `account:read` no admiten CORS: solo se pueden llamar desde un servidor. > **Nota:** La clave y sus permisos se crean desde el panel (**API**), no con una petición a la API: crear una clave exige iniciar sesión, no se hace con otra clave. ## Dónde vale Una clave solo funciona en las rutas `GET` de esta documentación, y cada una con su permiso: las de lectura de contenido ([Blogs](https://blogelia.com/docs/blogs) y [Artículos](https://blogelia.com/docs/articulos)), las de [Analítica](https://blogelia.com/docs/analitica) y las de cuenta ([Cuenta](https://blogelia.com/docs/cuenta), [Artículos (todos los estados)](https://blogelia.com/docs/articulos-todos), [Generaciones](https://blogelia.com/docs/generaciones), [Piloto automático](https://blogelia.com/docs/piloto-automatico) y [Entregas](https://blogelia.com/docs/entregas)). En cualquier otra ruta, la respuesta es `401` con el mensaje «Esta ruta no admite claves de API». ## Si la autenticación falla | Respuesta | Cuándo | | --- | --- | | `401` «Falta el token de acceso» | No hay cabecera `Authorization: Bearer …`. | | `401` «Clave de API no válida» | La clave no existe, está revocada o no tiene el formato `pk_live_` + 32 hexadecimales. | | `403` `plan_required` | Tu organización está en el plan Free. | | `403` `ORGANIZATION_SUSPENDED` | Tu organización está suspendida. | | `403` `insufficient_scope` | La clave es válida pero no tiene el permiso de la ruta. Ver [Permisos](#scopes). | ## No uses la clave en el navegador Las tres rutas de lectura de contenido aceptan peticiones desde cualquier origen (CORS) pero sin cookies ni credenciales. Aun así, cualquiera que abra tu web podría leer la clave. Haz la petición desde tu servidor y entrega el resultado al navegador. Más consejos en [Seguridad](https://blogelia.com/docs/seguridad). ### CORS - Solo `GET` y `HEAD` en las tres rutas de lectura de contenido (`content:read`), desde cualquier `Origin`, sin credenciales. Las rutas de `stats:read` y `account:read` no llevan CORS. - Cabeceras expuestas al navegador: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` y `Retry-After`. --- # Seguridad > Lista de comprobación para integrar la API, los webhooks y la publicación en tu propia API de Blogenia con el menor riesgo posible. Versión web: https://blogelia.com/docs/seguridad Una integración segura son pocas costumbres bien aplicadas. Repasa esta lista antes de salir a producción. ## Claves de API - **Permisos mínimos.** Marca solo los [permisos](https://blogelia.com/docs/autenticacion#scopes) que la integración necesita. Una web que muestra artículos necesita `content:read`; un dashboard de visitas, solo `stats:read`. - **Dashboard de analítica: una clave solo con `stats:read`.** No lee artículos ni datos de la cuenta. Si además necesitas créditos o estados de artículos, usa una segunda clave aparte con `account:read`. - **Nunca `account:read` en el navegador ni en código de front-end.** Da acceso a datos internos de la cuenta (créditos, generaciones, piloto, entregas). Úsala solo en tu servidor; esas rutas no admiten CORS a propósito. - **Los permisos no se amplían.** Si una clave se queda corta, crea otra con los permisos nuevos y revoca la antigua: así una clave filtrada nunca gana acceso. Ver [Rotar una clave](#rotar). - **Solo en el servidor.** Nunca en el navegador, en una app móvil ni en código que se descargue: cualquiera podría leerla. Pide los datos desde tu servidor y entrega el resultado al visitante. - **Variables de entorno o gestor de secretos**, nunca en el repositorio. Si una clave llegó a git, trátala como filtrada. - **Una clave por integración o entorno** (producción, pruebas, cada servicio). El panel te deja tener hasta 10 claves activas y ponerles nombre. - **Revoca las que no uses.** En **API**, la lista muestra el último uso de cada clave; revoca las que llevan tiempo sin usarse. - **Mira las cabeceras `X-RateLimit-*`** para detectar un uso anormal. Ver [Límites de uso](https://blogelia.com/docs/limites). ### Rotar una clave Las claves no caducan solas, así que rótalas tú. Recomendamos hacerlo cada 90 días y de inmediato si sospechas que se ha filtrado. 1. En el [panel](https://app.blogelia.com/api), crea una clave nueva. 2. Despliega tu servidor con la clave nueva. 3. Comprueba que funciona y que la antigua ya no aparece como usada. 4. **Revoca** la antigua. Deja de funcionar al instante en el proceso que la revoca y, como máximo, 30 segundos después en el resto. ## Webhooks - **Solo `https`.** El panel no admite otra cosa en producción. - **Verifica la firma** de cada petición con una comparación en tiempo constante (`timingSafeEqual`, `compare_digest`, `hash_equals`). Ver el código en [Webhooks](https://blogelia.com/docs/webhooks#firma). - **Rechaza timestamps antiguos.** La firma cubre `Blogenia-Timestamp`: descarta lo que tenga más de 5 minutos. - **Sé idempotente.** Guarda el valor de `Blogenia-Delivery` y no proceses dos veces el mismo. - **Responde `2xx` rápido** y procesa en segundo plano: tienes 10 segundos. - **No uses el cuerpo para autorizar nada.** Úsalo como aviso y, si necesitas datos fiables, pide el artículo a la [API](https://blogelia.com/docs/articulos) con tu clave. - **Trata el secreto como una contraseña**: variable de entorno, nunca en git ni en logs. ### Rotar el secreto de un webhook El panel muestra el secreto una sola vez y no lo regenera. Para cambiarlo: 1. Añade un webhook nuevo con la misma URL (o una nueva) y copia su secreto. 2. Haz que tu servidor acepte las dos firmas durante un tiempo, o despliega con el secreto nuevo. 3. Elimina el webhook antiguo desde **API** en el panel. ## Receptor de “Publicar en tu propia API” - **Acepta solo peticiones con tu credencial.** Blogenia envía la clave de acceso que configuraste como `Authorization: Bearer ` y la guarda cifrada (AES-256-GCM). Rechaza con `401` cualquier otra petición, y compara la clave en tiempo constante. - **Usa `Idempotency-Key` y `sourceId`** para no duplicar artículos ante reintentos. - **Sanea o limita el HTML** también en tu lado (lista blanca de etiquetas y atributos) antes de mostrarlo. - **Limita las peticiones** con un rate limit en esas rutas. - **Expón solo las rutas necesarias**: comprobar, crear, consultar, actualizar y borrar bajo la ruta que configuraste. - **Una credencial distinta por entorno** (producción y pruebas). - **Solo `https`** y un certificado válido: Blogenia no se conecta a otras direcciones. ### Cambiar la credencial En **Blog**, pestaña **Publicación**, escribe la clave nueva en **Clave de acceso** y guarda. Antes, haz que tu API acepte la nueva. Vuelve a comprobar la conexión. > **Nota:** Si ves algo raro o sospechas una filtración, revoca la clave o elimina el webhook primero y analiza después. --- # Límites de uso > Peticiones por minuto de la API de Blogenia según el plan, cabeceras X-RateLimit y qué hacer ante un 429. Versión web: https://blogelia.com/docs/limites Cada clave tiene un límite de peticiones por minuto que depende del plan de tu organización. | Plan | Peticiones por minuto y clave | | --- | --- | | Free | Sin acceso a la API (`403` `plan_required`) | | Starter | 60 | | Pro | 300 | El contador es una ventana fija de 60 segundos por clave y cuenta todas las peticiones autenticadas con ella, también las que acaban en `400` o `404`. ## Cabeceras Toda respuesta a una petición con clave incluye: | Cabecera | Significado | | --- | --- | | `X-RateLimit-Limit` | Peticiones permitidas por ventana. | | `X-RateLimit-Remaining` | Peticiones que quedan en la ventana actual. | | `X-RateLimit-Reset` | Segundos hasta que se reinicia la ventana. | | `Retry-After` | Solo en `429`: segundos que debes esperar. | Las respuestas llevan `Cache-Control: private, no-store`. ## Cuando superas el límite Recibes `429 Too Many Requests` con `code: "rate_limited"` y la cabecera `Retry-After`. **Respuesta 429** ```json { "code": "rate_limited", "statusCode": 429, "error": "Too Many Requests", "message": "Has superado el límite de peticiones de tu plan. Inténtalo de nuevo en unos segundos.", "requestId": "0b8e2f6c-6d63-4d0e-9c1a-5d8f3c1d7a11" } ``` ## Buenas prácticas - Espera `Retry-After` segundos antes de reintentar. - Guarda en tu servidor los artículos que ya has leído en lugar de pedirlos en cada visita de tu web. - Para enterarte de las novedades usa [webhooks](https://blogelia.com/docs/webhooks) en lugar de consultar la lista cada pocos segundos. > **Nota:** Probar muchas claves que no existen desde la misma IP también devuelve `429` con `code: "rate_limited"`. --- # Errores > Forma de las respuestas de error de la API de Blogenia, códigos HTTP y códigos de error propios. Versión web: https://blogelia.com/docs/errores Los errores usan el código HTTP correspondiente y un cuerpo JSON con la misma forma. **Cuerpo de error** ```json { "statusCode": 404, "error": "Not Found", "message": "Blog no encontrado", "requestId": "0b8e2f6c-6d63-4d0e-9c1a-5d8f3c1d7a11" } ``` **Campos** | Campo | Tipo | Descripción | | --- | --- | --- | | `statusCode` | number | Código HTTP. | | `error` | string | Nombre estándar del código HTTP. | | `message` | string \| string[] | Explicación en español. En errores de validación puede ser una lista de textos. | | `requestId` | string | Identificador de la petición. Indícalo si escribes a soporte. | | `code` | string | Solo en algunos errores: código estable para tratarlo en tu programa. | ## Códigos HTTP | Código | Significado | | --- | --- | | `400` | Un parámetro no es válido o no existe (por ejemplo `limit=500`, `page=0` o `format=xml`). En las rutas de analítica y cuenta, un rango de fechas no válido lleva un `code`. | | `401` | Falta la clave, no es válida o la ruta no admite claves. Ver [Autenticación](https://blogelia.com/docs/autenticacion). | | `403` | Tu plan no incluye la API o la organización está suspendida. | | `404` | El blog o el artículo no existe, no es de tu organización, el identificador está mal formado o el artículo no está publicado. | | `429` | Has superado el límite de tu plan. Ver [Límites de uso](https://blogelia.com/docs/limites). | | `5xx` | Error nuestro. Reintenta con espera creciente. | ## Códigos de error propios | `code` | HTTP | Cuándo | | --- | --- | --- | | `plan_required` | 403 | La organización está en el plan Free. | | `ORGANIZATION_SUSPENDED` | 403 | La organización está suspendida. | | `insufficient_scope` | 403 | La clave no tiene el permiso de la ruta. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). | | `owner_required` | 403 | Al crear en el panel una clave con `account:read` sin ser el propietario de la cuenta. | | `invalid_date` | 400 | Una fecha de `from`, `to`, `desde` o `hasta` no es un `YYYY-MM-DD` real. | | `invalid_range` | 400 | La fecha inicial es posterior a la final. | | `range_in_future` | 400 | `to` es una fecha futura. | | `range_too_long` | 400 | El rango de `from` a `to` supera los 400 días. | | `rate_limited` | 429 | Límite de peticiones superado. | > **Consejo:** Trata el error por `statusCode` y, si existe, por `code`. El texto de `message` puede cambiar. --- # Paginación > Cómo paginar la lista de artículos de la API de Blogenia con page y limit, y cómo sincronizar solo las novedades. Versión web: https://blogelia.com/docs/paginacion La lista de artículos se pagina con `page` y `limit`. El orden es siempre el de publicación, del más reciente al más antiguo. **Parámetros** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `page` | integer | No | Número de página, desde 1 (por defecto 1). Máximo 500. | | `limit` | integer | No | Artículos por página, de 1 a 50 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Artículos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Artículos publicados que cumplen el filtro, en todas las páginas. | Hay más páginas mientras `page * limit` sea menor que `total`. Una página sin resultados devuelve `data: []`. **Recorrer todos los artículos** *Node.js* ```js const base = 'https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles'; const headers = { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }; const articles = []; for (let page = 1; ; page++) { const res = await fetch(base + '?limit=50&page=' + page, { headers }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); articles.push(...body.data); if (page * body.limit >= body.total) break; } ``` *Python* ```python import os import requests base = "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles" headers = {"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]} articles, page = [], 1 while True: res = requests.get(base, params={"limit": 50, "page": page}, headers=headers, timeout=10) res.raise_for_status() body = res.json() articles += body["data"] if page * body["limit"] >= body["total"]: break page += 1 ``` ## Solo las novedades Para no leer siempre toda la lista, usa `publishedAfter` (fecha ISO 8601) y recibirás solo los artículos publicados después de esa fecha. Guarda en tu sistema el `publishedAt` más reciente que hayas leído y úsalo en la siguiente consulta. > **Nota:** Cada petición cuenta para tu [límite de uso](https://blogelia.com/docs/limites). Con `limit=50` necesitas menos peticiones. > **Nota:** Las listas de [cuenta y analítica](https://blogelia.com/docs/cuenta) usan los mismos `page` y `limit` y responden con `{ data, page, limit, total }`, pero `page` admite hasta 10000. --- # Blogs > Endpoint GET /blogs/{blogId} de la API de Blogenia: datos públicos de un blog de tu organización. Versión web: https://blogelia.com/docs/blogs Un blog es el sitio donde Blogenia publica tus artículos. Esta ruta devuelve sus datos públicos. ## Obtener un blog **`GET /blogs/{blogId}`**: Devuelve un blog de tu organización. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog (24 caracteres hexadecimales). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | string | Identificador del blog. | | `name` | string | Nombre. | | `slug` | string | Identificador corto usado en la URL pública. | | `url` | string | URL pública del blog (el dominio propio si está activo). | | `description` | string | Descripción del negocio. Puede estar vacía. | | `language` | string | Idioma del blog, por ejemplo `es`. | | `category` | object | Categoría principal: `{ slug, name }`. | | `author` | object | Autor que firma los artículos: `{ name, bio }`. | | `articleCount` | integer | Artículos publicados. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0", headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "id": "66f1c0a2b3c4d5e6f7a8b9c0", "name": "Reformas Ejemplo", "slug": "reformas-ejemplo", "url": "https://reformas-ejemplo.blogelia.com", "description": "Reformas integrales en Madrid desde 2008.", "language": "es", "category": { "slug": "reformas", "name": "Reformas" }, "author": { "name": "Reformas Ejemplo", "bio": "Reformas integrales en Madrid desde 2008." }, "articleCount": 12 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | El blog existe y es de tu organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | Plan Free u organización suspendida. | | `404` | El blog no existe, es de otra organización o está suspendido. | | `429` | Límite del plan superado. | --- # Artículos > Endpoints GET /blogs/{blogId}/articles y GET /articles/{id} de la API de Blogenia: lista paginada y artículo completo en JSON, HTML o Markdown. Versión web: https://blogelia.com/docs/articulos Solo se devuelven artículos **publicados**. Los borradores, los programados y los pendientes de aprobación no aparecen. ## Listar artículos **`GET /blogs/{blogId}/articles`**: Lista los artículos publicados de un blog, del más reciente al más antiguo. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog. | **Parámetros de consulta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 500). Ver [Paginación](https://blogelia.com/docs/paginacion). | | `limit` | integer | No | Artículos por página, de 1 a 50 (por defecto 20). | | `category` | string | No | Slug de una categoría del blog. Si no existe, la lista llega vacía. | | `publishedAfter` | string | No | Fecha ISO 8601: solo artículos publicados después. | | `status` | string | No | Si lo envías, solo admite `published`. | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Artículos, con los campos de la tabla siguiente. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de artículos que cumplen el filtro. | **Campos de cada artículo de la lista** | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | string | Identificador del artículo (24 caracteres hexadecimales). | | `title` | string | Título. | | `slug` | string | Último segmento de la URL pública. | | `url` | string | URL pública del artículo en el blog. | | `excerpt` | string | Extracto. Cadena vacía si no tiene. | | `publishedAt` | string | Fecha de publicación, ISO 8601 en UTC. | | `updatedAt` | string | Última modificación del contenido, ISO 8601 en UTC. Si no consta, igual que `publishedAt`. | | `category` | object | Categoría del artículo: `{ slug, name }`. Si no tiene una propia, la categoría principal del blog. | | `coverImage` | object \| null | Portada: `{ url, alt }`. `null` si el artículo no tiene. | | `readingMinutes` | number | Minutos de lectura estimados (mínimo 1). | | `faqs` | array | Preguntas frecuentes: `[{ q, a }]`. Lista vacía si no tiene. | > **Nota:** La lista no incluye el contenido. Para obtenerlo, pide el artículo por su `id`. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles?limit=5&category=reformas" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles?limit=5&category=reformas', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles", params={"limit": 5, "category": "reformas"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "url": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina", "excerpt": "Precios orientativos y qué incluye cada partida.", "publishedAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-02T10:30:00.000Z", "category": { "slug": "reformas", "name": "Reformas" }, "coverImage": { "url": "https://cdn.example.com/cover.webp", "alt": "Cocina reformada" }, "readingMinutes": 5, "faqs": [{ "q": "¿Cuánto tarda una reforma?", "a": "Entre dos y cuatro semanas." }] } ], "page": 1, "limit": 5, "total": 12 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Lista devuelta (puede estar vacía). | | `400` | Parámetro no válido, por ejemplo `limit` fuera de 1–50, `page` mayor que 500 o `publishedAfter` que no es una fecha. | | `401` | Clave ausente, no válida o revocada. | | `403` | Plan Free u organización suspendida. | | `404` | El blog no existe, es de otra organización o está suspendido. | | `429` | Límite del plan superado. | ## Obtener un artículo **`GET /articles/{id}`**: Devuelve un artículo publicado con su contenido. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | string | Sí | Identificador del artículo (24 caracteres hexadecimales). | **Parámetros de consulta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `format` | string | No | `json` (por defecto), `html` o `markdown`. Con `html` o `markdown` la respuesta es solo el contenido, con `Content-Type` `text/html` o `text/markdown`. | Con `format=json` la respuesta tiene todos los campos de un artículo de la lista y, además, estos: **Campos adicionales (format=json)** | Campo | Tipo | Descripción | | --- | --- | --- | | `contentHtml` | string | Fragmento HTML saneado, listo para incrustar en tu página. | | `contentMarkdown` | string | El mismo contenido en Markdown. | | `seo` | object | `{ title, description, canonical }`. Si el artículo no define los suyos, se rellenan con el título, el extracto y la `url`. | | `sponsored` | boolean | `true` en artículos patrocinados: sus enlaces externos llevan `rel="sponsored"`. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1", headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "url": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina", "excerpt": "Precios orientativos y qué incluye cada partida.", "publishedAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-02T10:30:00.000Z", "category": { "slug": "reformas", "name": "Reformas" }, "coverImage": null, "readingMinutes": 5, "faqs": [], "contentHtml": "

Precio medio

…

", "contentMarkdown": "## Precio medio\n\n…", "seo": { "title": "¿Cuánto cuesta reformar una cocina?", "description": "Precios orientativos y qué incluye cada partida.", "canonical": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina" }, "sponsored": false } ``` **Solo el HTML o el Markdown** ```bash curl "https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1?format=markdown" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Artículo devuelto. | | `400` | `format` no es `json`, `html` ni `markdown`. | | `401` | Clave ausente, no válida o revocada. | | `403` | Plan Free u organización suspendida. | | `404` | No existe, no está publicado, es de otra organización o su blog está suspendido. | | `429` | Límite del plan superado. | --- # Cuenta > Endpoints GET /account y GET /account/credits/ledger de la API de Blogenia: plan, créditos, uso del mes, límites, blogs y movimientos de créditos. Versión web: https://blogelia.com/docs/cuenta Datos de tu organización: plan, créditos, uso del mes, límites y blogs. Útiles para pintar el resumen de tu propio panel. > **Aviso:** Estas rutas exigen una clave con el permiso `account:read`, que da acceso a datos internos de tu cuenta. Úsalas solo desde tu servidor. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). ## Obtener la cuenta **`GET /account`**: Devuelve la organización de la clave, con créditos, uso, límites y blogs. Permiso necesario: `account:read`. No tiene parámetros. **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `organization` | object | `{ id, name, plan, createdAt }`. `plan` es `free`, `starter` o `pro`. | | `credits` | object | `{ balance, buckets }`. `balance` son los créditos disponibles (lotes sin caducar menos las reservas en curso). `buckets` son los lotes: `[{ period, remaining, expiresAt }]`, con `period` en `YYYY-MM`. | | `usage` | object | Uso del mes natural en Europe/Madrid: `{ period, articlesPublished, articlesGenerated, generationsFailed }`. | | `limits` | object | `{ blogs, apiRatePerMin, autopilotPerWeek }`: blogs permitidos, peticiones por minuto de la API y artículos por semana del piloto automático. | | `blogs` | array | Blogs de la organización (hasta 100), del más antiguo al más reciente: `[{ id, name, slug, publicUrl, status, createdAt }]`. `status` es `active` o `suspended`. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/account" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/account', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/account", headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "organization": { "id": "66f1c0a2b3c4d5e6f7a8b900", "name": "Reformas Ejemplo S.L.", "plan": "starter", "createdAt": "2026-08-12T10:15:00.000Z" }, "credits": { "balance": 940, "buckets": [ { "period": "2026-10", "remaining": 120, "expiresAt": "2026-12-01T00:00:00.000Z" }, { "period": "2026-11", "remaining": 820, "expiresAt": "2027-01-01T00:00:00.000Z" } ] }, "usage": { "period": "2026-11", "articlesPublished": 4, "articlesGenerated": 6, "generationsFailed": 1 }, "limits": { "blogs": 1, "apiRatePerMin": 60, "autopilotPerWeek": 3 }, "blogs": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c0", "name": "Reformas Ejemplo", "slug": "reformas-ejemplo", "publicUrl": "https://reformas-ejemplo.blogelia.com", "status": "active", "createdAt": "2026-08-12T10:20:00.000Z" } ] } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Movimientos de créditos **`GET /account/credits/ledger`**: Lista los movimientos de créditos, del más reciente al más antiguo. Permiso necesario: `account:read`. Es la misma información que el historial de créditos del panel. **Parámetros de consulta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 10000). | | `limit` | integer | No | Elementos por página, de 1 a 50 (por defecto 20). | | `from` | string | No | Primer día, `YYYY-MM-DD` en Europe/Madrid, incluido. Sin `from` ni `to`, se devuelve todo el historial. | | `to` | string | No | Último día, `YYYY-MM-DD` en Europe/Madrid, incluido. Los dos son opcionales e independientes: con solo `from`, hasta hoy; con solo `to`, desde el principio. Sin límite de días; `from` no puede ser posterior a `to`. | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Elementos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de elementos que cumplen el filtro, en todas las páginas. | | `data[].at` | string | Fecha del movimiento, ISO 8601 en UTC. | | `data[].type` | string | `grant` (abono del plan), `reserve` (reserva al empezar una generación), `settle` (cobro real), `release` (devolución de una reserva), `expire` (caducidad), `refund` o `admin` (ajuste manual). | | `data[].amount` | integer | Créditos con signo: negativo es gasto. | | `data[].ref` | string | Referencia interna del movimiento. Puede faltar. | | `data[].articleId` | string | Artículo asociado, si se conoce. Puede faltar. | | `data[].generationId` | string | Generación asociada. Puede faltar. | > **Nota:** `reserve` y `release` son movimientos internos de la reserva de una generación: para saber lo que has gastado, cuenta los `settle`. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/account/credits/ledger?from=2026-11-01&limit=10" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/account/credits/ledger?from=2026-11-01&limit=10', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/account/credits/ledger", params={"from": "2026-11-01", "limit": 10}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "at": "2026-11-03T09:00:41.000Z", "type": "settle", "amount": -8, "ref": "gen-66f1c0a2b3c4d5e6f7a8b9e0", "articleId": "66f1c0a2b3c4d5e6f7a8b9c1", "generationId": "66f1c0a2b3c4d5e6f7a8b9e0" }, { "at": "2026-11-01T00:00:00.000Z", "type": "grant", "amount": 1099 } ], "page": 1, "limit": 10, "total": 2 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Fechas o paginación no válidas (`invalid_date`, `invalid_range`, `range_in_future`, `range_too_long`) o parámetro desconocido. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Analítica > Visitas de tus blogs y artículos por la API de Blogenia: series por día, semana o mes, ranking de artículos y total de la cuenta con el permiso stats:read. Versión web: https://blogelia.com/docs/analitica Cuatro rutas devuelven las visitas de tu cuenta, de un blog o de un artículo, como las APIs de analítica de cualquier herramienta web. > **Nota:** Estas rutas exigen una clave con el permiso `stats:read`, que **no** da acceso a artículos ni a datos de la cuenta. Es el permiso pensado para un dashboard. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes) y [Construir tu dashboard](https://blogelia.com/docs/construir-dashboard). ## Reglas comunes Las rutas de analítica comparten estas reglas: - **Fechas.** `from` y `to` son días `YYYY-MM-DD` en la zona Europe/Madrid, ambos incluidos. Por defecto, los últimos 30 días; el máximo son 400 días por petición. - **Frescura.** Los datos los vuelca un proceso que se ejecuta cada 10 minutos. `updatedAt` es la hora de la última pasada (ISO 8601 en UTC) y puede ser `null` si todavía no se conoce. - **Día en curso.** El grupo de la serie que contiene hoy aún no ha terminado y lleva `partial: true`. - **Únicos aproximados.** `uniquesApprox` se calcula con un identificador anónimo que cambia cada día y sin cookies, así que es una aproximación. No se pueden sumar días como si fueran personas distintas: por eso el total se llama `uniquesApproxSum`. - **Series densas.** Los días sin visitas aparecen con `0`; no hay huecos. - **Caché.** Blogenia guarda cada respuesta 60 segundos por organización y parámetros. **Parámetros de consulta comunes** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `from` | string | No | Primer día, `YYYY-MM-DD` en Europe/Madrid, incluido. Por defecto, 29 días antes de `to` (los últimos 30 días). | | `to` | string | No | Último día, `YYYY-MM-DD` en Europe/Madrid, incluido. Por defecto, hoy. No puede ser futuro. | | `interval` | string | No | `day` (por defecto), `week` (semanas ISO, empiezan en lunes) o `month` (meses naturales). Siempre en Europe/Madrid. | > **Agrupar por semana y mes:** `date` es el primer día del grupo: el propio día con `interval=day`, el lunes con `week` y el día 1 con `month`. En el primer grupo puede ser anterior a `from`. `uniquesApprox` de una semana o un mes es la suma de sus días. ## Visitas de toda la cuenta **`GET /account/stats`**: Total y serie de visitas de todos los blogs de la organización. Permiso necesario: `stats:read`. Admite `from`, `to` e `interval`. **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `from` | string | Primer día del rango aplicado. | | `to` | string | Último día del rango aplicado. | | `interval` | string | `day`, `week` o `month`. | | `totals` | object | `{ views, uniquesApproxSum, articlesPublished }`. `articlesPublished` cuenta los artículos cuya publicación cae en el rango. | | `series` | array | `[{ date, views, uniquesApprox, partial? }]`. `partial: true` solo aparece en el grupo que contiene hoy. | | `blogs` | array | Visitas por blog en el rango: `[{ blogId, name, views }]`. | | `updatedAt` | string \| null | Hora del último volcado de estadísticas, ISO 8601 en UTC. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/account/stats?interval=week" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/account/stats?interval=week', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/account/stats", params={"interval": "week"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "from": "2026-10-06", "to": "2026-11-04", "interval": "week", "totals": { "views": 1840, "uniquesApproxSum": 1315, "articlesPublished": 9 }, "series": [ { "date": "2026-10-05", "views": 310, "uniquesApprox": 232 }, { "date": "2026-10-12", "views": 402, "uniquesApprox": 290 }, { "date": "2026-10-19", "views": 455, "uniquesApprox": 318 }, { "date": "2026-10-26", "views": 520, "uniquesApprox": 365 }, { "date": "2026-11-02", "views": 153, "uniquesApprox": 110, "partial": true } ], "blogs": [{ "blogId": "66f1c0a2b3c4d5e6f7a8b9c0", "name": "Reformas Ejemplo", "views": 1840 }], "updatedAt": "2026-11-04T14:50:00.000Z" } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Fechas o parámetros no válidos. Con `code`: `invalid_date` (no es una fecha `YYYY-MM-DD` real), `invalid_range` (`from` posterior a `to`), `range_in_future` (`to` es futuro) o `range_too_long` (más de 400 días). Un parámetro que no existe también da `400`. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `stats:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Visitas de un blog **`GET /blogs/{blogId}/stats`**: Total y serie de visitas de un blog. Permiso necesario: `stats:read`. Admite `from`, `to` e `interval`. La respuesta tiene los mismos campos que la de la cuenta, sin `blogs`. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats?from=2026-10-01&to=2026-10-31" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats?from=2026-10-01&to=2026-10-31', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats", params={"from": "2026-10-01", "to": "2026-10-31"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "from": "2026-10-01", "to": "2026-10-31", "interval": "day", "totals": { "views": 1420, "uniquesApproxSum": 1010, "articlesPublished": 7 }, "series": [ { "date": "2026-10-01", "views": 38, "uniquesApprox": 29 }, { "date": "2026-10-02", "views": 51, "uniquesApprox": 40 } ], "updatedAt": "2026-11-04T14:50:00.000Z" } ``` La lista `series` real trae un punto por cada día del rango; aquí se acorta. **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Fechas o parámetros no válidos. Con `code`: `invalid_date` (no es una fecha `YYYY-MM-DD` real), `invalid_range` (`from` posterior a `to`), `range_in_future` (`to` es futuro) o `range_too_long` (más de 400 días). Un parámetro que no existe también da `400`. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `stats:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Ranking de artículos **`GET /blogs/{blogId}/stats/articles`**: Los artículos de un blog ordenados por visitas en el rango. Permiso necesario: `stats:read`. **Parámetros** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog (en la ruta). | | `from` | string | No | Primer día, `YYYY-MM-DD` en Europe/Madrid, incluido. Por defecto, 29 días antes de `to` (los últimos 30 días). | | `to` | string | No | Último día, `YYYY-MM-DD` en Europe/Madrid, incluido. Por defecto, hoy. No puede ser futuro. | | `sort` | string | No | `-views` (por defecto, de más a menos visitas) o `views` (de menos a más). | | `limit` | integer | No | Artículos a devolver, de 1 a 100 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `from` | string | Primer día del rango aplicado. | | `to` | string | Último día del rango aplicado. | | `data` | array | `[{ articleId, title, slug, url, publishedAt, views }]`. `publishedAt` es `null` si el artículo ya no está publicado. | | `updatedAt` | string \| null | Hora del último volcado de estadísticas. | > **Nota:** Solo aparecen los artículos con visitas en el rango. Los artículos borrados no se listan. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats/articles?limit=5" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats/articles?limit=5', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/stats/articles", params={"limit": 5}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "from": "2026-10-06", "to": "2026-11-04", "data": [ { "articleId": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "url": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina", "publishedAt": "2026-10-01T09:00:00.000Z", "views": 612 } ], "updatedAt": "2026-11-04T14:50:00.000Z" } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Fechas o parámetros no válidos. Con `code`: `invalid_date` (no es una fecha `YYYY-MM-DD` real), `invalid_range` (`from` posterior a `to`), `range_in_future` (`to` es futuro) o `range_too_long` (más de 400 días). Un parámetro que no existe también da `400`. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `stats:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Visitas de un artículo **`GET /articles/{id}/stats`**: Total y serie de visitas de un artículo. Permiso necesario: `stats:read`. Admite `from`, `to` e `interval`. Los puntos de la serie no llevan `uniquesApprox`, y `totals` solo tiene `views`. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `id` | string | Sí | Identificador del artículo. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1/stats?interval=month" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1/stats?interval=month', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/articles/66f1c0a2b3c4d5e6f7a8b9c1/stats", params={"interval": "month"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "from": "2026-10-06", "to": "2026-11-04", "interval": "month", "totals": { "views": 612 }, "series": [ { "date": "2026-10-01", "views": 580 }, { "date": "2026-11-01", "views": 32, "partial": true } ], "updatedAt": "2026-11-04T14:50:00.000Z" } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Fechas o parámetros no válidos. Con `code`: `invalid_date` (no es una fecha `YYYY-MM-DD` real), `invalid_range` (`from` posterior a `to`), `range_in_future` (`to` es futuro) o `range_too_long` (más de 400 días). Un parámetro que no existe también da `400`. | | `404` | El artículo no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `stats:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Artículos (todos los estados) > Lista y resumen de los artículos de un blog de Blogenia en cualquier estado (borrador, programado, publicado…) con el permiso account:read. Versión web: https://blogelia.com/docs/articulos-todos La ruta [Artículos](https://blogelia.com/docs/articulos) solo devuelve lo publicado. Estas dos rutas muestran los artículos de un blog en **cualquier estado**, sin el contenido. > **Aviso:** Exigen una clave con el permiso `account:read`. Úsalas solo desde tu servidor. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). ## Listar artículos de cualquier estado **`GET /blogs/{blogId}/articles/all`**: Lista paginada de los artículos de un blog, del más reciente al más antiguo por fecha de creación. **Parámetros** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog (en la ruta). | | `estado` | string | No | `draft`, `pending_approval`, `scheduled`, `published`, `unpublished` o `rejected`. | | `q` | string | No | Texto contenido en el título (hasta 80 caracteres, sin distinguir mayúsculas). | | `desde` | string | No | Día `YYYY-MM-DD` (Europe/Madrid), incluido: solo elementos creados desde ese día. | | `hasta` | string | No | Día `YYYY-MM-DD` (Europe/Madrid), incluido: solo elementos creados hasta ese día. No puede ser anterior a `desde`. | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 10000). | | `limit` | integer | No | Elementos por página, de 1 a 50 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Elementos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de elementos que cumplen el filtro, en todas las páginas. | | `data[].id` | string | Identificador del artículo. | | `data[].title` | string | Título. | | `data[].slug` | string | Último segmento de la URL pública. | | `data[].status` | string | Estado, uno de los valores de `estado`. | | `data[].source` | string | `manual`, `generated` (generado a petición) o `autopilot`. | | `data[].createdAt` | string | Fecha de creación, ISO 8601 en UTC. | | `data[].scheduledAt` | string | Fecha programada. Solo si la tiene. | | `data[].publishedAt` | string | Fecha de publicación. Solo si la tiene. | | `data[].wordCount` | integer | Número de palabras. | | `data[].requiresHumanReview` | boolean | `true` si el artículo necesita revisión humana antes de publicarse. | | `data[].url` | string | URL pública. Solo en artículos publicados. | > **Nota:** La lista no incluye el contenido. El texto de un artículo **publicado** se pide con `GET /articles/{id}` ([Artículos](https://blogelia.com/docs/articulos)), con un permiso `content:read`. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/all?estado=scheduled&limit=10" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/all?estado=scheduled&limit=10', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/all", params={"estado": "scheduled", "limit": 10}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "Cinco errores al reformar un baño", "slug": "cinco-errores-al-reformar-un-bano", "status": "scheduled", "source": "autopilot", "createdAt": "2026-11-02T09:00:12.000Z", "scheduledAt": "2026-11-05T08:00:00.000Z", "wordCount": 1280, "requiresHumanReview": false } ], "page": 1, "limit": 10, "total": 1 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Parámetro no válido (estado desconocido, fechas con `code` `invalid_date` o `invalid_range`, `q` demasiado largo) o parámetro desconocido. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Resumen de artículos **`GET /blogs/{blogId}/articles/summary`**: Recuento por estado y listas de lo que viene. Permiso necesario: `account:read`. Solo lleva el parámetro de ruta `blogId`. **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `counts` | object | Número de artículos por estado. Siempre trae los seis estados, con `0` si no hay ninguno. | | `period` | string | Mes natural en curso (`YYYY-MM`, Europe/Madrid). | | `publishedThisMonth` | integer | Artículos publicados en `period`. | | `publishedPreviousMonth` | integer | Artículos publicados el mes anterior. | | `upcomingScheduled` | array | Hasta 10 artículos programados, el más próximo primero: `[{ id, title, scheduledAt }]`. | | `pendingApproval` | array | Hasta 10 artículos pendientes de aprobar: `[{ id, title, scheduledAt? }]`. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/summary" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/summary', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/articles/summary", headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "counts": { "draft": 2, "pending_approval": 1, "scheduled": 3, "published": 20, "unpublished": 0, "rejected": 1 }, "period": "2026-11", "publishedThisMonth": 4, "publishedPreviousMonth": 8, "upcomingScheduled": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "Cinco errores al reformar un baño", "scheduledAt": "2026-11-05T08:00:00.000Z" } ], "pendingApproval": [ { "id": "66f1c0a2b3c4d5e6f7a8b9c2", "title": "Cuándo conviene cambiar las ventanas" } ] } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Generaciones > Historial de generaciones de artículos de un blog de Blogenia por API: estado, pasos, créditos y código de error, con el permiso account:read. Versión web: https://blogelia.com/docs/generaciones Una generación es el trabajo que escribe un artículo. Esta ruta lista el historial de un blog para ver qué se ha generado, qué está en marcha y qué ha fallado. > **Aviso:** Exige una clave con el permiso `account:read`. Úsala solo desde tu servidor. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). ## Historial de generaciones **`GET /blogs/{blogId}/generations`**: Lista paginada de generaciones, de la más reciente a la más antigua. **Parámetros** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog (en la ruta). | | `estado` | string | No | `queued`, `running`, `done` o `failed`. | | `status` | string | No | Solo admite `active`: las que están en curso (`queued` o `running`). Si lo envías, se ignora `estado`. | | `desde` | string | No | Día `YYYY-MM-DD` (Europe/Madrid), incluido: solo elementos creados desde ese día. | | `hasta` | string | No | Día `YYYY-MM-DD` (Europe/Madrid), incluido: solo elementos creados hasta ese día. No puede ser anterior a `desde`. | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 10000). | | `limit` | integer | No | Elementos por página, de 1 a 50 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Elementos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de elementos que cumplen el filtro, en todas las páginas. | | `data[].id` | string | Identificador de la generación. | | `data[].status` | string | `queued`, `running`, `done` o `failed`. | | `data[].source` | string | Cómo se pidió: `topic`, `keyword`, `brief` o `autopilot`. | | `data[].keyword` | string | Palabra clave, si se indicó. | | `data[].createdAt` | string | Fecha de creación, ISO 8601 en UTC. | | `data[].finishedAt` | string | Fecha de fin. Solo si ha terminado. | | `data[].articleId` | string | Artículo resultante. Solo en las que acabaron bien (`done`). | | `data[].error` | object | `{ code }`. Solo en las fallidas. `code` puede ser `ai_disabled`, `ai_unavailable`, `cost_cap`, `quota`, `duplicate_topic` o `internal`. | | `data[].credits` | object | `{ estimated, charged? }`: créditos estimados y, cuando se ha cobrado, los cobrados. | | `data[].steps` | array | Pasos: `[{ key, status }]`. `key` es `outline`, `sections`, `verify`, `links`, `faq`, `review`, `cover` o `save`; `status` es `pending`, `running`, `done`, `failed` o `skipped`. | > **Nota:** Las generaciones se conservan 30 días. La respuesta nunca incluye prompts ni salidas del modelo. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/generations?estado=done&limit=10" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/generations?estado=done&limit=10', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/generations", params={"estado": "done", "limit": 10}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "id": "66f1c0a2b3c4d5e6f7a8b9e0", "status": "done", "source": "autopilot", "keyword": "reforma de cocina precio", "createdAt": "2026-11-03T08:58:00.000Z", "finishedAt": "2026-11-03T09:00:41.000Z", "articleId": "66f1c0a2b3c4d5e6f7a8b9c1", "credits": { "estimated": 8, "charged": 8 }, "steps": [ { "key": "outline", "status": "done" }, { "key": "sections", "status": "done" }, { "key": "verify", "status": "done" }, { "key": "links", "status": "done" }, { "key": "faq", "status": "skipped" }, { "key": "review", "status": "done" }, { "key": "cover", "status": "done" }, { "key": "save", "status": "done" } ] } ], "page": 1, "limit": 10, "total": 1 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Parámetro no válido (estado desconocido, fechas con `invalid_date` o `invalid_range`) o parámetro desconocido. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Piloto automático > Configuración, próximos huecos y últimos resultados del piloto automático de un blog de Blogenia por API, con el permiso account:read. Versión web: https://blogelia.com/docs/piloto-automatico El piloto automático genera y publica artículos en los días y a la hora que eliges. Esta ruta de solo lectura devuelve su configuración, los próximos huecos y lo que pasó en los últimos. > **Aviso:** Exige una clave con el permiso `account:read`. Úsala solo desde tu servidor. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). ## Obtener el piloto automático **`GET /blogs/{blogId}/autopilot`**: Configuración, próximos huecos y últimos resultados del piloto de un blog. **Parámetros de ruta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog. | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `enabled` | boolean | `true` si el piloto está activado. | | `mode` | string | `review` (los artículos esperan tu aprobación) o `auto` (se publican solos). | | `scheduleType` | string | `weekly` (días fijos de la semana) o `interval` (cada cierto número de días). | | `days` | array | Días de la semana de publicación, de 0 (domingo) a 6 (sábado). Vacío con `interval`. | | `time` | string | Hora de publicación `HH:00`, en Europe/Madrid. | | `nextSlots` | array | Hasta 10 próximos huecos: `[{ slotAt, status, topicTitle? }]`. `status` puede ser `planned`, `generating`, `pending_approval`, `scheduled`, `published`, `skipped`, `failed` o `missed`. | | `lastSlots` | array | Hasta 10 últimos huecos: `[{ slotAt, result, articleId?, reason? }]`. `result` toma los mismos valores que `status`. `reason` explica un hueco saltado o fallido, por ejemplo `quota`, `no_topics`, `plan`, `suspended`, `missed`, `global_cap` o `generation_failed`. | | `credits` | object | `{ perArticle, available }`: créditos que cuesta un artículo medio con los modelos del blog y créditos disponibles. | **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/autopilot" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/autopilot', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/autopilot", headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "enabled": true, "mode": "review", "scheduleType": "weekly", "days": [1, 3], "time": "09:00", "nextSlots": [ { "slotAt": "2026-11-05T08:00:00.000Z", "status": "generating", "topicTitle": "Cinco errores al reformar un baño" } ], "lastSlots": [ { "slotAt": "2026-11-03T08:00:00.000Z", "result": "published", "articleId": "66f1c0a2b3c4d5e6f7a8b9c1" }, { "slotAt": "2026-10-29T08:00:00.000Z", "result": "skipped", "reason": "no_topics" } ], "credits": { "perArticle": 8, "available": 940 } } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Entregas > Estado de la publicación externa (WordPress o tu API) y de las entregas de webhooks de Blogenia por API, con el permiso account:read. Versión web: https://blogelia.com/docs/entregas Dos rutas te dicen si Blogenia ha entregado bien tus artículos: la publicación externa en WordPress o en tu propia API, y los avisos de [webhooks](https://blogelia.com/docs/webhooks). > **Aviso:** Exigen una clave con el permiso `account:read`. Úsalas solo desde tu servidor. Ver [Permisos](https://blogelia.com/docs/autenticacion#scopes). ## Publicación externa de un blog **`GET /blogs/{blogId}/publications`**: Estado de cada artículo enviado a WordPress o a tu API. **Parámetros** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `blogId` | string | Sí | Identificador del blog (en la ruta). | | `estado` | string | No | `pending`, `processing`, `succeeded`, `failed` o `needs_attention`. | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 10000). | | `limit` | integer | No | Elementos por página, de 1 a 50 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Elementos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de elementos que cumplen el filtro, en todas las páginas. | | `data[].articleId` | string | Artículo enviado. | | `data[].provider` | string | `wordpress` o `custom` (tu propia API). | | `data[].status` | string | `pending` (por enviar), `processing`, `succeeded`, `failed` o `needs_attention` (el resultado es dudoso y requiere que lo revises). | | `data[].remoteUrl` | string | URL del artículo en el destino. Solo si se conoce. | | `data[].lastAttemptAt` | string | Fecha del último intento, ISO 8601 en UTC. | | `data[].attempts` | integer | Intentos realizados. | | `data[].error` | string | Texto del último error. Solo en `failed` y `needs_attention`. | > **Nota:** Si el blog no tiene configurada la publicación externa, `data` llega vacío con `total: 0`. La lista cubre los 1000 artículos con actividad más reciente. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/publications?estado=failed" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/publications?estado=failed', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/blogs/66f1c0a2b3c4d5e6f7a8b9c0/publications", params={"estado": "failed"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "articleId": "66f1c0a2b3c4d5e6f7a8b9c1", "provider": "custom", "status": "failed", "lastAttemptAt": "2026-11-03T09:02:10.000Z", "attempts": 3, "error": "El destino respondió con 502" } ], "page": 1, "limit": 20, "total": 1 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Parámetro no válido (por ejemplo, un `estado` desconocido) o parámetro desconocido. | | `404` | El blog no existe o es de otra organización. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | ## Entregas de webhooks **`GET /webhooks/deliveries`**: Entregas de los webhooks de la organización, de la más reciente a la más antigua. **Parámetros de consulta** | Nombre | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `estado` | string | No | `pending`, `delivered` o `failed`. | | `desde` | string | No | Día `YYYY-MM-DD` (Europe/Madrid), incluido: solo entregas creadas desde ese día. | | `page` | integer | No | Página, desde 1 (por defecto 1, máximo 10000). | | `limit` | integer | No | Elementos por página, de 1 a 50 (por defecto 20). | **Campos de la respuesta** | Campo | Tipo | Descripción | | --- | --- | --- | | `data` | array | Elementos de la página. | | `page` | integer | Página devuelta. | | `limit` | integer | Tamaño de página aplicado. | | `total` | integer | Total de elementos que cumplen el filtro, en todas las páginas. | | `data[].id` | string | Identificador de la entrega. | | `data[].webhookId` | string | Webhook al que se envió. | | `data[].event` | string | Evento, por ejemplo `article.published`. | | `data[].status` | string | `pending`, `delivered` o `failed`. | | `data[].attempts` | integer | Intentos realizados. | | `data[].lastStatusCode` | integer | Código HTTP de la última respuesta de tu servidor. Solo si hubo respuesta. | | `data[].createdAt` | string | Fecha de creación, ISO 8601 en UTC. | > **Nota:** Nunca se devuelve el cuerpo enviado ni el secreto del webhook. **Petición** *curl* ```bash curl "https://api.blogelia.com/v1/webhooks/deliveries?estado=failed&desde=2026-11-01" \ -H "Authorization: Bearer $BLOGENIA_API_KEY" ``` *Node.js* ```js const res = await fetch('https://api.blogelia.com/v1/webhooks/deliveries?estado=failed&desde=2026-11-01', { headers: { Authorization: 'Bearer ' + process.env.BLOGENIA_API_KEY }, }); if (!res.ok) throw new Error('HTTP ' + res.status); const body = await res.json(); console.log(body); ``` *Python* ```python import os import requests res = requests.get( "https://api.blogelia.com/v1/webhooks/deliveries", params={"estado": "failed", "desde": "2026-11-01"}, headers={"Authorization": "Bearer " + os.environ["BLOGENIA_API_KEY"]}, timeout=10, ) res.raise_for_status() print(res.json()) ``` **Respuesta** ```json { "data": [ { "id": "66f1c0a2b3c4d5e6f7a8b9d0", "webhookId": "66f1c0a2b3c4d5e6f7a8b9d1", "event": "article.published", "status": "failed", "attempts": 5, "lastStatusCode": 500, "createdAt": "2026-11-03T09:00:03.120Z" } ], "page": 1, "limit": 20, "total": 1 } ``` **Respuestas** | Código | Cuándo | | --- | --- | | `200` | Respuesta devuelta. | | `400` | Parámetro no válido (estado desconocido, `desde` que no es una fecha real) o parámetro desconocido. | | `401` | Clave ausente, no válida o revocada. | | `403` | La clave no tiene el permiso `account:read` (`code: "insufficient_scope"`), tu plan es Free (`plan_required`) o la organización está suspendida (`ORGANIZATION_SUSPENDED`). | | `429` | Límite del plan superado. Ver [Límites de uso](https://blogelia.com/docs/limites). | --- # Webhooks > Recibe un POST firmado en tu servidor cada vez que Blogenia publica un artículo. Eventos, firma HMAC, ejemplos en Node, Python y PHP, y reintentos. Versión web: https://blogelia.com/docs/webhooks Un webhook es una URL de tu servidor a la que Blogenia envía un `POST` con JSON cuando ocurre un evento. Así te enteras de cada publicación sin consultar la API una y otra vez. ## Crear un webhook 1. En el [panel](https://app.blogelia.com/api), abre **API** y pulsa **Añadir webhook**. 2. Indica la URL que recibirá los avisos. Debe ser `https`, sin usuario ni contraseña, apuntar a una dirección pública y usar el puerto 80 o el 443. 3. Copia el secreto (`whsec_…`). **Solo se muestra una vez.** 4. Pulsa **Enviar prueba** para comprobar que tu servidor responde. Puedes tener hasta 5 webhooks. Solo las organizaciones con plan Starter o Pro reciben avisos. ## Eventos | Evento | Cuándo se envía | | --- | --- | | `article.published` | Se publica un artículo: a mano, programado o por el piloto automático. Si un artículo se despublica y se vuelve a publicar, se envía otro aviso. | | `ping` | Al pulsar **Enviar prueba** en el panel. No se puede suscribir. | ## La petición Blogenia envía un `POST` con `Content-Type: application/json` y estas cabeceras: | Cabecera | Contenido | | --- | --- | | `Blogenia-Event` | `article.published` o `ping`. | | `Blogenia-Delivery` | Identificador de la entrega. Es el mismo en todos los reintentos y coincide con `id` del cuerpo. | | `Blogenia-Timestamp` | Segundos Unix en que se firmó. Cambia en cada reintento. | | `Blogenia-Signature` | `v1=` seguido de la firma HMAC-SHA256 en hexadecimal. | | `User-Agent` | `Blogenia-Webhooks/1.0` | **Campos del cuerpo** | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | string | Identificador de la entrega. | | `event` | string | `article.published` o `ping`. | | `createdAt` | string | ISO 8601 en UTC. | | `data` | object | En `article.published`: `{ article, blogId }`, donde `article` tiene los mismos campos que un artículo de la [lista](https://blogelia.com/docs/articulos). En `ping`: `{ webhookId }`. | > **Nota:** El aviso no lleva el contenido del artículo. Pídelo con `GET /articles/{id}` ([Artículos](https://blogelia.com/docs/articulos)) usando `data.article.id`. **Cuerpo de article.published** ```json { "id": "66f1c0a2b3c4d5e6f7a8b9d0", "event": "article.published", "createdAt": "2026-10-01T09:00:03.120Z", "data": { "article": { "id": "66f1c0a2b3c4d5e6f7a8b9c1", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "url": "https://reformas-ejemplo.blogelia.com/cuanto-cuesta-reformar-una-cocina", "excerpt": "Precios orientativos y qué incluye cada partida.", "publishedAt": "2026-10-01T09:00:00.000Z", "updatedAt": "2026-10-02T10:30:00.000Z", "category": { "slug": "reformas", "name": "Reformas" }, "coverImage": { "url": "https://cdn.example.com/cover.webp", "alt": "Cocina reformada" }, "readingMinutes": 5, "faqs": [{ "q": "¿Cuánto tarda una reforma?", "a": "Entre dos y cuatro semanas." }] }, "blogId": "66f1c0a2b3c4d5e6f7a8b9c0" } } ``` **Cuerpo de ping** ```json { "id": "66f1c0a2b3c4d5e6f7a8b9d1", "event": "ping", "createdAt": "2026-10-01T09:00:03.120Z", "data": { "webhookId": "66f1c0a2b3c4d5e6f7a8b9d2" } } ``` ## Verificar la firma Comprueba siempre la firma: así sabes que el aviso lo envía Blogenia y que nadie lo ha modificado. 1. Lee el cuerpo **en bruto**, tal como llega, sin volver a serializar el JSON. 2. Calcula `HMAC-SHA256` con tu secreto completo (incluido el prefijo `whsec_`) sobre el texto `.`, donde `` es el valor de `Blogenia-Timestamp`. Pásalo a hexadecimal y antepón `v1=`. 3. Compáralo con `Blogenia-Signature` en tiempo constante. 4. Rechaza la petición si el timestamp tiene más de 5 minutos de antigüedad. **Receptor con verificación** *Node.js* ```js import crypto from 'node:crypto'; import express from 'express'; const SECRET = process.env.BLOGENIA_WEBHOOK_SECRET; // whsec_… const app = express(); function isValid(rawBody, headers) { const timestamp = headers['blogenia-timestamp']; const received = headers['blogenia-signature'] || ''; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > 300) return false; const expected = 'v1=' + crypto.createHmac('sha256', SECRET).update(timestamp + '.' + rawBody).digest('hex'); return ( received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) ); } // express.raw conserva el cuerpo en bruto. app.post('/webhooks/blogenia', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); if (!isValid(rawBody, req.headers)) return res.sendStatus(400); const event = JSON.parse(rawBody); if (event.event === 'article.published') { console.log('Publicado:', event.data.article.title); } res.sendStatus(200); }); app.listen(3000); ``` *Python* ```python import hashlib import hmac import json import os import time from flask import Flask, abort, request SECRET = os.environ["BLOGENIA_WEBHOOK_SECRET"] # whsec_… app = Flask(__name__) def is_valid(raw_body: bytes, headers) -> bool: timestamp = headers.get("Blogenia-Timestamp", "") received = headers.get("Blogenia-Signature", "") try: age = abs(time.time() - int(timestamp)) except ValueError: return False if age > 300: return False signed = timestamp.encode() + b"." + raw_body expected = "v1=" + hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(received, expected) @app.post("/webhooks/blogenia") def blogenia(): raw_body = request.get_data() # cuerpo en bruto if not is_valid(raw_body, request.headers): abort(400) event = json.loads(raw_body) if event["event"] == "article.published": print("Publicado:", event["data"]["article"]["title"]) return "", 200 ``` *PHP* ```php 300) { http_response_code(400); exit; } $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); if (!hash_equals($expected, $received)) { http_response_code(400); exit; } $event = json_decode($rawBody, true); if ($event['event'] === 'article.published') { error_log('Publicado: ' . $event['data']['article']['title']); } http_response_code(200); ``` ## Tu respuesta - Responde con cualquier código `2xx` en menos de **10 segundos**. Es la única respuesta que cuenta como entrega correcta. - Cualquier otro código, un error de red o superar los 10 segundos cuenta como fallo y se reintenta. No seguimos redirecciones: un `3xx` es un fallo. - Si respondes `410 Gone`, desactivamos el webhook y no reintentamos. Puedes reactivarlo en el panel. - Leemos como máximo los primeros 4096 bytes de tu respuesta. ## Reintentos Cada entrega tiene hasta 5 intentos: el primero inmediato y los demás tras la espera indicada desde el fallo anterior. | Intento | Cuándo | | --- | --- | | 1 | Al publicarse el artículo | | 2 | 1 minuto después del fallo | | 3 | 5 minutos después | | 4 | 30 minutos después | | 5 | 2 horas después | Si fallan los cinco, la entrega queda como fallida. Puedes ver el estado de las entregas y reintentar una fallida desde **API** en el panel. ## Buenas prácticas - **Responde rápido** y procesa después: guarda el aviso, devuelve `200` y haz el trabajo pesado en segundo plano. - **Ignora duplicados** con `Blogenia-Delivery`: el mismo aviso puede llegar más de una vez. - **No te fíes del orden**: dos publicaciones seguidas pueden llegar cambiadas, sobre todo con reintentos. - **Verifica siempre la firma** y el timestamp antes de hacer nada con el cuerpo. - **Guarda el secreto** en una variable de entorno, nunca en el código. - Para el contenido, pide el artículo con la [API de lectura](https://blogelia.com/docs/articulos) en lugar de confiar solo en el aviso. > **Nota:** Los avisos solo salen de organizaciones con plan Starter o Pro y no se envían de blogs suspendidos. > **Consejo:** Lista completa de comprobaciones para webhooks en [Seguridad](https://blogelia.com/docs/seguridad#webhooks). --- # Publicar en tu propia API > Contrato HTTP que debe implementar tu web para que Blogenia le envíe los artículos: rutas, cabeceras, cuerpo, respuesta, tiempos y reintentos, con un receptor de ejemplo. Versión web: https://blogelia.com/docs/publicar-en-tu-api Si tu web tiene su propia API, Blogenia puede publicar en ella cada artículo: lo crea al publicarlo, lo actualiza cuando lo editas y lo retira si lo despublicas o lo borras. Tu web recibe el artículo en HTML y decide cómo mostrarlo. Si tu web es WordPress no necesitas esto: elige **WordPress** en la misma pantalla. > **Nota:** Próximamente: portada, preguntas frecuentes, SEO y etiquetas. Hoy el cuerpo no las incluye. ## Conectar tu API 1. En el [panel](https://app.blogelia.com/blog), abre **Blog** y la pestaña **Publicación**. Solo el propietario de la cuenta puede configurarla. 2. Elige **API propia** e indica la dirección de tu web, la clave de acceso y, si cambian, las rutas. 3. Guarda y comprueba la conexión. | Campo | Reglas | | --- | --- | | Dirección de la web | `https`, sin usuario, parámetros ni fragmento, y puerto 443. Debe ser una dirección pública. | | Clave de acceso | Se envía como `Authorization: Bearer `. Se guarda cifrada y no se vuelve a mostrar. | | Ruta de artículos | Por defecto `/articles`. Debe empezar por `/` y llevar solo letras, números, `_`, `-` y `/`. | | Ruta de comprobación | Por defecto `/health`. Mismas reglas. | ## Lo que debe implementar tu API | Operación | Petición | Cuándo | | --- | --- | --- | | Comprobar | `GET {ruta de comprobación}` | Al comprobar la conexión. | | Crear | `POST {ruta de artículos}` | Se publica un artículo por primera vez. | | Actualizar | `PUT {ruta de artículos}/{id}` | Se edita un artículo ya publicado. | | Despublicar | `PUT {ruta de artículos}/{id}` con `status: "draft"` | El artículo deja de estar publicado. | | Borrar | `DELETE {ruta de artículos}/{id}` | Se borra el artículo en Blogenia. | | Consultar | `GET {ruta de artículos}/{id}` | Solo al conciliar a mano una creación dudosa (ver más abajo). | `{id}` es el identificador que tu API devolvió al crear el artículo. ### Cabeceras | Cabecera | Valor | | --- | --- | | `Authorization` | `Bearer ` | | `Accept` | `application/json` | | `Content-Type` | `application/json` | | `Idempotency-Key` | En `POST`, `PUT` y `DELETE`: 64 caracteres hexadecimales. Los reintentos de la misma operación llevan la misma clave. | ### Cuerpo de POST y PUT **Campos** | Campo | Tipo | Descripción | | --- | --- | --- | | `sourceId` | string | UUID estable del artículo en Blogenia. Úsalo para no crear duplicados. | | `title` | string | Título. | | `slug` | string | Slug del artículo. | | `content` | string | Contenido en HTML. Los enlaces a otros artículos ya publicados en tu API apuntan a su `url`; los enlaces a artículos aún no publicados allí llegan sin `href`. | | `excerpt` | string | Extracto. Cadena vacía si no tiene. | | `status` | string | `published`, o `draft` al despublicar. | **Cuerpo** ```json { "sourceId": "0b8e2f6c-6d63-4d0e-9c1a-5d8f3c1d7a11", "title": "¿Cuánto cuesta reformar una cocina?", "slug": "cuanto-cuesta-reformar-una-cocina", "content": "

Precio medio

…

", "excerpt": "Precios orientativos y qué incluye cada partida.", "status": "published" } ``` `DELETE` no lleva cuerpo. ### Tu respuesta Responde con un código `2xx` y un objeto JSON. `GET` de comprobación solo necesita un objeto JSON cualquiera. **Campos de la respuesta a POST y PUT** | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | string \| number | Identificador del artículo en tu API: un entero positivo o un texto de hasta 200 caracteres con letras, números, `_` y `-`. En `PUT` debe ser el mismo del que está en la ruta. | | `url` | string | Opcional. URL pública del artículo en tu web. Solo se guarda si es `https`, del mismo origen que la dirección configurada y de menos de 2048 caracteres. | **Respuesta** ```json { "id": "42", "url": "https://tuweb.example/blog/cuanto-cuesta-reformar-una-cocina" } ``` - `DELETE`: cualquier `2xx` vale (también sin cuerpo). `404` y `410` se tratan como «ya borrado». - No seguimos redirecciones: un `3xx` cuenta como error. Configura la dirección definitiva. ## Tiempos, tamaños y reintentos | Concepto | Valor | | --- | --- | | Tiempo máximo por petición | 15 segundos. | | Cuerpo de la petición | Hasta 2 MiB. | | Respuesta | Hasta 256 KB. | | Actualizar, despublicar y borrar | Un error de red, un `5xx` o un `429` se reintenta solo, hasta 3 intentos en total: tras 1 y 2 minutos. Otros `4xx` no se reintentan. | | Crear | Un `429` se reintenta igual; otro `4xx` es un fallo definitivo. Un error de red o un `5xx` no se reintenta: Blogenia no sabe si tu API llegó a crear el artículo y pide al propietario que lo revise en el panel antes de repetir. | Un fallo no automático se puede reintentar a mano desde el artículo en el panel. ### Creaciones dudosas Si un `POST` termina en error de red o `5xx`, el propietario revisa en tu web si el artículo se creó. Si existe, indica su `id` en el panel y Blogenia lo enlaza tras comprobar que `GET {ruta de artículos}/{id}` devuelve el mismo `id` y el mismo `slug`. Implementa ese `GET` y haz el `POST` idempotente por `sourceId` o `Idempotency-Key` para evitar duplicados. ## Receptor de ejemplo Un servidor Express mínimo que cumple el contrato. Guarda los artículos en memoria: úsalo como punto de partida. **Node.js con Express** ```js import express from 'express'; const TOKEN = process.env.BLOGENIA_TOKEN; // la clave de acceso que pegas en Blogenia const articles = new Map(); // id -> artículo const bySource = new Map(); // sourceId -> id let nextId = 1; const app = express(); app.use(express.json({ limit: '3mb' })); // Autenticación Bearer app.use((req, res, next) => { if (req.get('authorization') !== 'Bearer ' + TOKEN) return res.sendStatus(401); next(); }); // Ruta de comprobación app.get('/health', (_req, res) => res.json({ ok: true })); // Crear (idempotente por sourceId) app.post('/articles', (req, res) => { const { sourceId, title, slug, content, excerpt, status } = req.body; let id = bySource.get(sourceId); if (!id) { id = String(nextId++); bySource.set(sourceId, id); } articles.set(id, { id, sourceId, title, slug, content, excerpt, status }); res.status(201).json({ id, url: 'https://tuweb.example/blog/' + slug }); }); // Consultar (lo usa Blogenia al conciliar) app.get('/articles/:id', (req, res) => { const a = articles.get(req.params.id); if (!a) return res.sendStatus(404); res.json({ id: a.id, slug: a.slug, url: 'https://tuweb.example/blog/' + a.slug }); }); // Actualizar o despublicar (status: "draft") app.put('/articles/:id', (req, res) => { if (!articles.has(req.params.id)) return res.sendStatus(404); const { sourceId, title, slug, content, excerpt, status } = req.body; const id = req.params.id; articles.set(id, { id, sourceId, title, slug, content, excerpt, status }); res.json({ id, url: 'https://tuweb.example/blog/' + slug }); }); // Borrar app.delete('/articles/:id', (req, res) => { articles.delete(req.params.id); res.sendStatus(204); }); app.listen(3000); ``` > **Consejo:** Buenas prácticas para tu receptor (credencial, idempotencia, HTML, límites) en [Seguridad](https://blogelia.com/docs/seguridad#publicar). > **Consejo:** Para probarlo en local, publica el servidor con un túnel HTTPS (la conexión exige `https` y una dirección pública). --- # Para agentes de IA > Cómo usan los agentes de IA la documentación de Blogenia: versión Markdown de cada página, llms.txt, llms-full.txt y la especificación OpenAPI. Versión web: https://blogelia.com/docs/agentes-ia Esta documentación está pensada también para modelos y agentes. Cada página existe en HTML y en Markdown limpio, sin menús ni scripts. ## Markdown de cada página Añade `.md` al nombre de la página, bajo `/docs/`: - `https://blogelia.com/docs/introduccion.md` (la portada, que en HTML está en `/docs`) - `https://blogelia.com/docs/autenticacion.md` - `https://blogelia.com/docs/webhooks.md` Las respuestas llevan `Content-Type: text/markdown; charset=utf-8`. Cada página HTML enlaza su versión Markdown con ``. ## llms.txt y llms-full.txt | Fichero | Contenido | | --- | --- | | [/llms.txt](https://blogelia.com/llms.txt) | Índice en formato [llmstxt.org](https://llmstxt.org): título, resumen y enlace a la versión Markdown de cada página. | | [/llms-full.txt](https://blogelia.com/llms-full.txt) | Toda la documentación en un solo documento, para cargarla de una vez. | ## Especificación OpenAPI Las rutas que aceptan clave están descritas en OpenAPI: [https://api.blogelia.com/docs-json](https://api.blogelia.com/docs-json). La interfaz para personas está en [https://api.blogelia.com/docs](https://api.blogelia.com/docs). ## Resumen para un agente - URL base: `https://api.blogelia.com/v1`. Solo lectura, JSON. - Autenticación: `Authorization: Bearer pk_live_…`. La clave la crea una persona en el panel; un agente no puede crearla. - Rutas de contenido (`content:read`): `GET /blogs/{blogId}`, `GET /blogs/{blogId}/articles`, `GET /articles/{id}` (con `?format=markdown` devuelve el artículo en Markdown). - Rutas de analítica (`stats:read`): `GET /account/stats`, `GET /blogs/{blogId}/stats`, `GET /blogs/{blogId}/stats/articles`, `GET /articles/{id}/stats`. - Rutas de cuenta (`account:read`): `GET /account`, `GET /account/credits/ledger`, `GET /blogs/{blogId}/articles/all`, `GET /blogs/{blogId}/articles/summary`, `GET /blogs/{blogId}/generations`, `GET /blogs/{blogId}/autopilot`, `GET /blogs/{blogId}/publications`, `GET /webhooks/deliveries`. - Permisos: cada clave lleva los suyos desde su creación y no cambian; sin el permiso, `403` `insufficient_scope`. Las rutas de `stats:read` y `account:read` no admiten CORS. - Límite: 60 peticiones por minuto (Starter) o 300 (Pro) por clave; ante `429`, espera `Retry-After` segundos. - Errores: `{ statusCode, error, message, requestId }` y a veces `code`. > **Aviso:** No pegues claves de API en prompts, repositorios ni páginas públicas. --- # Changelog > Historial de versiones de la API pública de Blogenia: qué incluye la versión actual, v1. Versión web: https://blogelia.com/docs/changelog ## v1 Versión actual, con la URL base `https://api.blogelia.com/v1`. - Lectura de blogs y artículos publicados: `GET /blogs/{blogId}`, `GET /blogs/{blogId}/articles` y `GET /articles/{id}` (JSON, HTML o Markdown). - Claves `pk_live_…` con límites por plan y permisos (`content:read`, `stats:read`, `account:read`). - Analítica de visitas: `GET /account/stats`, `GET /blogs/{blogId}/stats`, `GET /blogs/{blogId}/stats/articles` y `GET /articles/{id}/stats`. - Datos de la cuenta: `GET /account`, movimientos de créditos, artículos en cualquier estado y su resumen, generaciones, piloto automático y entregas (publicación externa y webhooks). - Webhook `article.published` con firma HMAC-SHA256. - Publicación en una API propia (`POST`, `PUT` y `DELETE`).