# 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 `<timestamp>.<cuerpo>`, donde `<timestamp>` 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
<?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 `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).
