Desarrolladores
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.
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
- En el panel, abre API y pulsa Añadir webhook.
- 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. - Copia el secreto (
whsec_…). Solo se muestra una vez. - 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. En ping: { webhookId }. |
{
"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"
}
}{
"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.
- Lee el cuerpo en bruto, tal como llega, sin volver a serializar el JSON.
- Calcula
HMAC-SHA256con tu secreto completo (incluido el prefijowhsec_) sobre el texto<timestamp>.<cuerpo>, donde<timestamp>es el valor deBlogenia-Timestamp. Pásalo a hexadecimal y antepónv1=. - Compáralo con
Blogenia-Signatureen tiempo constante. - Rechaza la petición si el timestamp tiene más de 5 minutos de antigüedad.
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);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
$secret = getenv('BLOGENIA_WEBHOOK_SECRET'); // whsec_…
$rawBody = file_get_contents('php://input'); // cuerpo en bruto
$timestamp = $_SERVER['HTTP_BLOGENIA_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_BLOGENIA_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 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
2xxen 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
3xxes 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
200y 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 en lugar de confiar solo en el aviso.