Webhook de contenido
Si tu web está hecha a mano —Astro, Next, Nuxt, Laravel o lo que sea— no necesitas ningún CMS. Cada vez que Sembalia publica o actualiza una pieza, te enviamos el contenido completo a tu endpoint y tú lo guardas donde quieras: tu base de datos, ficheros Markdown o un rebuild.
1. Configuración
En el panel, dentro de tu proyecto, ve a Ajustes → destino de publicación y elige Webhook. Necesitas dos cosas:
- Una URL HTTPS que acepte POST. Debe ser accesible desde internet: si está detrás de un cortafuegos o en localhost, no llegaremos.
- Un secreto largo y aleatorio. Genéralo con openssl rand -base64 32 y guárdalo también en tu servidor.
Ese secreto es lo único que separa tu blog de que cualquiera que descubra tu URL publique en él. No lo reutilices de otro sitio ni lo subas al repositorio.
2. Eventos
El tipo de evento viaja en la cabecera X-Sembalia-Event y también dentro del cuerpo. Hay dos:
- content.published — se ha publicado una pieza nueva. Insértala.
- content.updated — una pieza que ya tenías ha cambiado. Localízala por su slug y actualízala.
El segundo llega más de lo que uno espera, y es buena señal: se dispara cuando reescribimos un título por una propuesta de Search Console, cuando el enlazado retroactivo añade un enlace a un artículo antiguo, o cuando entra un backlink de la red. Si ignoras content.updated, tu web se queda con la versión vieja y las mejoras no llegan a Google.
3. Qué recibes
Un POST con Content-Type: application/json y este cuerpo. El HTML viene ya saneado: solo etiquetas de formato, sin scripts ni atributos de evento.
{
"event": "content.published",
"sentAt": "2026-08-28T09:00:00.000Z",
"projectId": "8f3c…",
"content": {
"id": "c1a2…",
"kind": "article",
"title": "Colorimetría personal: guía práctica",
"slug": "colorimetria-personal-guia-practica",
"metaDescription": "Qué es el subtono, cómo identificarlo y qué colores…",
"html": "<p>…</p><h2>…</h2>",
"outline": ["Qué es el subtono", "Cómo identificarlo"],
"words": 1240,
"publishedAt": "2026-08-28T09:00:00.000Z",
"publishedUrl": null,
"author": { "name": "Elena Ruiz", "slug": "elena-ruiz", "bio": "…", "role": "…", "url": "https://…" },
"images": [
{ "kind": "hero", "url": "https://…/hero.png", "mimeType": "image/png",
"width": 1536, "height": 864, "alt": "…" }
],
"categories": [{ "id": "…", "name": "Guías", "slug": "guias", "primary": true }],
"tags": [{ "id": "…", "name": "Color", "slug": "color" }],
"internalLinks": [
{ "anchor": "test de color", "targetSlug": "test-de-color", "targetTitle": "…",
"targetUrl": "https://tuweb.com/blog/test-de-color" }
],
"jsonLd": [
{ "@context": "https://schema.org", "@type": "Article",
"headline": "…", "author": { "@type": "Person", "name": "…" },
"datePublished": "…", "image": ["https://…/hero.png"] },
{ "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [] }
]
}
}Las imágenes se sirven desde nuestro almacenamiento. Si prefieres alojarlas tú, descárgalas al recibir el webhook y reescribe las URLs antes de guardar.
El campo jsonLd trae los datos estructurados de schema.org (Article o NewsArticle, y FAQPage si el contenido tiene preguntas frecuentes). Va aparte del HTML a propósito: los datos estructurados se declaran en el <head> de la página, no en el cuerpo del artículo, y el HTML que te enviamos viene saneado sin etiquetas <script>. Píntalo en tu plantilla como un <script type="application/ld+json"> por cada bloque del array.
Solo declaramos lo que existe de verdad en el contenido: si una pieza no tiene imagen, ese campo no aparece, y sin un autor de tu equipo firma tu marca (Organization), nunca una persona inventada. Un dato estructurado que no se corresponde con lo que se ve en la página puede acarrear una penalización manual de Google.
4. Verifica la firma
Cada envío lleva la cabecera X-Sembalia-Signature con el valor sha256=<hmac>, donde el HMAC-SHA256 se calcula sobre el cuerpo completo usando tu secreto. Compruébalo antes de tocar tu base de datos.
Verifica sobre el cuerpo CRUDO, sin parsear. Si lo parseas a objeto y lo vuelves a serializar, los bytes cambian —el orden de las claves, los espacios— y la firma no cuadrará nunca. Es el error número uno al integrar un webhook firmado.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const SECRET = process.env.SEMBALIA_WEBHOOK_SECRET;
app.post(
'/webhooks/sembalia',
express.raw({ type: 'application/json' }), // el cuerpo CRUDO, sin parsear
(req, res) => {
const recibida = req.get('x-sembalia-signature') ?? '';
const esperada =
'sha256=' + createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(recibida);
const b = Buffer.from(esperada);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send('firma invalida');
}
const { event, content } = JSON.parse(req.body.toString('utf8'));
if (event === 'content.published') insertarArticulo(content);
if (event === 'content.updated') actualizarPorSlug(content.slug, content);
// Devolver la URL final cierra el ciclo de medición
res.json({ url: `https://tuweb.com/blog/${content.slug}` });
},
);<?php
$secreto = getenv('SEMBALIA_WEBHOOK_SECRET');
$cuerpo = file_get_contents('php://input'); // crudo, antes de decodificar
$recibida = $_SERVER['HTTP_X_SEMBALIA_SIGNATURE'] ?? '';
$esperada = 'sha256=' . hash_hmac('sha256', $cuerpo, $secreto);
if (!hash_equals($esperada, $recibida)) {
http_response_code(401);
exit('firma invalida');
}
$datos = json_decode($cuerpo, true);
guardar($datos['event'], $datos['content']);
header('Content-Type: application/json');
echo json_encode(['url' => 'https://tuweb.com/blog/' . $datos['content']['slug']]);import hmac, hashlib, os
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRETO = os.environ["SEMBALIA_WEBHOOK_SECRET"]
@app.post("/webhooks/sembalia")
def webhook():
cuerpo = request.get_data() # bytes crudos
esperada = "sha256=" + hmac.new(SECRETO.encode(), cuerpo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperada, request.headers.get("X-Sembalia-Signature", "")):
return "firma invalida", 401
datos = request.get_json()
guardar(datos["event"], datos["content"])
return jsonify(url=f"https://tuweb.com/blog/{datos['content']['slug']}")Compara en tiempo constante (timingSafeEqual, hash_equals, compare_digest). Un == normal filtra información por el tiempo que tarda en fallar.
5. Responde con la URL final
Si respondes con {"url": "https://tuweb.com/blog/el-slug"}, guardamos esa dirección como la URL publicada de la pieza.
No es opcional en la práctica: sin ella no podemos cruzar los datos de Search Console con cada artículo, y pierdes el ciclo que da sentido al producto — qué publicaste, qué posición cogió, y si aplicar una propuesta sirvió de algo o no.
Responde 2xx solo cuando lo hayas guardado de verdad. Un 200 nos dice que está hecho y no volveremos a intentarlo.
6. Cambiar tus artículos anteriores
Cuando conectas tu web leemos los artículos que ya tenías para que el análisis los tenga en cuenta. Por defecto no los tocamos: si Search Console dice que uno está saliendo arriba con mal CTR, te damos el título y la meta que recomendamos y los cambias tú.
Si prefieres automatizarlo, actívalo en Ajustes → “Aplicar cambios sobre tu contenido anterior”. A partir de ahí te llega este evento y lo aplica tu sistema.
{
"event": "content.edit_requested",
"sentAt": "2026-09-02T09:00:00.000Z",
"projectId": "3f1c...",
"proposalId": "9a2e...",
"target": {
"url": "https://tuweb.com/blog/riego-por-goteo"
},
"changes": {
"title": "Riego por goteo: guía práctica para huerto urbano",
"metaDescription": "Cómo montar un riego por goteo paso a paso, con los errores que más se repiten."
},
"reason": "Posición media 4,2 con un CTR del 1,1%: la página sale arriba pero el título no se hace clicar."
}Fíjate en lo que NO viene: no hay html ni slug, porque ese artículo no es nuestro. La pieza que identifica la página es target.url, que es exactamente la URL que leímos de tu sitemap. Aplica solo los campos presentes en changes: si algún día solo cambia la meta, title no vendrá.
Puedes responder {"applied": true} para decirnos que ya está. No lo damos por bueno sin más: volvemos a leer la página y comprobamos que cambió de verdad. Esa comprobación es lo que arranca la medición del efecto, y es también la razón de que el dato sea fiable — medimos cambios que han ocurrido, no cambios que alguien dijo que iba a hacer.
Revisamos tu archivo cada dos semanas, y cada dos días los artículos con un cambio pendiente. Si tu sistema lo aplica en diferido (un rebuild), no hace falta que hagas nada: lo detectaremos igual, solo que más tarde.
Este evento modifica páginas que escribiste tú. Está apagado por defecto a propósito. Si lo activas, trata target.url como lo que es: una instrucción para editar contenido publicado, así que verifica la firma antes de tocar nada — igual que con los otros eventos.
6. Reintentos y garantías
- Reintentamos hasta 3 veces con espera creciente entre intentos.
- Cada entrega queda registrada con su estado y número de intentos, así que una caída pasajera de tu servidor no pierde contenido.
- Cualquier respuesta que no sea 2xx cuenta como fallo y provoca reintento.
- La entrega es "al menos una vez": si tu respuesta se pierde por el camino, puedes recibir el mismo evento dos veces. Haz la inserción idempotente usando el slug o el id.
Si el webhook falla del todo, el contenido no se pierde: sigue disponible en la API de contenido y puedes re-sincronizarlo cuando quieras.
7. Probarlo
- Levanta tu endpoint y hazlo accesible desde internet (en desarrollo sirve un túnel tipo ngrok o cloudflared).
- Configura la URL y el secreto en el panel.
- Entra en el calendario, abre una pieza que esté lista y pulsa Publicar.
- Revisa tus logs: debe llegar el POST, la firma debe cuadrar y tu respuesta con {"url": …} debe aparecer en el panel como "Ver publicado".
8. Si algo no funciona
- No llega nada: comprueba que la URL es HTTPS, pública y responde al POST. Un dominio que no resuelve o una IP privada se rechazan por seguridad antes de salir.
- La firma nunca cuadra: casi siempre es que estás verificando sobre el cuerpo ya parseado en vez del crudo. Mira el punto 4.
- Llegan eventos repetidos: es esperado. Tu inserción debe ser idempotente por slug.
- Se publica pero el panel no muestra la URL: no estás devolviendo {"url": …} o no respondes JSON.
- Tu framework consume el cuerpo antes de que lo veas (Express con express.json global, por ejemplo): registra el parser crudo solo para esta ruta, antes del global.
Alternativa: la API de contenido
Si tu web es estática y se reconstruye, quizá te encaje mejor tirar del contenido en el build en vez de recibirlo. Tienes GET /public/v1/contents y GET /public/v1/contents/:slug, autenticadas con una API key de proyecto que creas en el panel.
Muchos montan las dos: el webhook como disparador del rebuild —ignorando el cuerpo— y la API como fuente durante el build.