Sembalia
Back to home

Content webhook

If your site is hand-built — Astro, Next, Nuxt, Laravel, anything — you do not need a CMS. Every time Sembalia publishes or updates a piece we send you the full content at your endpoint and you store it wherever you like: your database, Markdown files or a rebuild.

1. Setup

In the panel, inside your project, go to Settings → publishing target and pick Webhook. You need two things:

  • An HTTPS URL that accepts POST. It must be reachable from the internet: behind a firewall or on localhost we will not get through.
  • A long random secret. Generate it with openssl rand -base64 32 and store it on your server too.

That secret is the only thing between your blog and anyone who discovers your URL publishing on it. Do not reuse it from elsewhere and do not commit it.

2. Events

The event type travels in the X-Sembalia-Event header and inside the body. There are three:

  • content.published — a new piece went live. Insert it.
  • content.updated — a piece you already have changed. Find it by slug and update it.
  • content.edit_requested — we ask you to change one of YOUR articles, the ones that existed before you connected us. Only arrives if you turn it on.

The second arrives more often than you would expect, and that is a good sign: it fires when we rewrite a title from a Search Console proposal, when retroactive linking adds a link to an older article, or when a backlink comes in. Ignore content.updated and your site keeps the stale version — the improvements never reach Google.

The third is different from the other two, and it is worth understanding why. In content.published and content.updated we send the FULL content, because we wrote it. In content.edit_requested we do not own the body — you wrote that article — so all that travels is your page URL and what we want changed. Your system decides how to apply it.

3. What you receive

A POST with Content-Type: application/json and this body. The HTML arrives sanitised: formatting tags only, no scripts or event attributes.

{
  "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": [] }
    ]
  }
}

Images are served from our storage. If you prefer to host them yourself, download them when the webhook arrives and rewrite the URLs before saving.

The jsonLd field carries schema.org structured data (Article or NewsArticle, plus FAQPage when the piece has frequently asked questions). It sits outside the HTML on purpose: structured data belongs in the page <head>, not in the article body, and the HTML we send is sanitised with no <script> tags. Render one <script type="application/ld+json"> per block in the array.

We only declare what actually exists in the content: if a piece has no image, that field is absent, and with no author from your team the byline is your brand (Organization), never an invented person. Structured data that does not match what the page shows can earn a manual penalty from Google.

4. Verify the signature

Every delivery carries an X-Sembalia-Signature header with sha256=<hmac>, where the HMAC-SHA256 is computed over the whole body using your secret. Check it before touching your database.

Verify against the RAW body, unparsed. If you parse it into an object and re-serialise, the bytes change — key order, whitespace — and the signature will never match. It is the number one mistake when integrating a signed webhook.

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']}")

Compare in constant time (timingSafeEqual, hash_equals, compare_digest). A plain == leaks information through how long it takes to fail.

5. Respond with the final URL

If you respond with {"url": "https://yoursite.com/blog/the-slug"}, we store that address as the published URL of the piece.

In practice it is not optional: without it we cannot match Search Console data to each article, and you lose the loop that makes the product worth having — what you published, what position it reached, and whether applying a proposal actually helped.

Only answer 2xx once you have really stored it. A 200 tells us it is done and we will not try again.

6. Changing your earlier articles

When you connect your site we read the articles you already had, so the analysis can take them into account. By default we do not touch them: if Search Console says one ranks well with a poor CTR, we give you the title and meta we recommend and you change them.

If you would rather automate it, turn it on in Settings → “Apply changes to your earlier content”. From then on this event arrives and your system applies it.

{
  "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."
}

Notice what is NOT there: no html and no slug, because that article is not ours. The page is identified by target.url, exactly the URL we read from your sitemap. Apply only the fields present in changes: if one day only the meta changes, title will not be sent.

You can respond {"applied": true} to tell us it is done. We do not simply take your word for it: we read the page again and check that it really changed. That check is what starts the effect measurement, and it is also why the number is trustworthy — we measure changes that happened, not changes someone said they would make.

We review your archive every two weeks, and every two days for articles with a pending change. If your system applies it asynchronously (a rebuild), you do not need to do anything: we will still detect it, just later.

This event modifies pages you wrote. It is off by default on purpose. If you turn it on, treat target.url for what it is — an instruction to edit published content — so verify the signature before touching anything, just as with the other events.

6. Retries and guarantees

  • We retry up to 3 times with growing backoff between attempts.
  • Every delivery is logged with its status and attempt count, so a brief outage on your side loses nothing.
  • Any non-2xx response counts as a failure and triggers a retry.
  • Delivery is at-least-once: if your response is lost in transit you may receive the same event twice. Make your insert idempotent on slug or id.

If the webhook fails entirely the content is not lost: it stays available in the content API and you can re-sync whenever you want.

7. Testing it

  • Bring up your endpoint and make it reachable from the internet (in development a tunnel like ngrok or cloudflared works).
  • Set the URL and secret in the panel.
  • Open the calendar, pick a piece that is ready and hit Publish.
  • Check your logs: the POST should arrive, the signature should match, and your {"url": …} response should show up in the panel as "View published".

8. Troubleshooting

  • Nothing arrives: check the URL is HTTPS, public and answers POST. A domain that does not resolve, or a private IP, is rejected for security before we even send.
  • The signature never matches: almost always you are verifying against the parsed body instead of the raw one. See step 4.
  • Duplicate events: expected. Your insert must be idempotent on slug.
  • It publishes but the panel shows no URL: you are not returning {"url": …}, or not responding JSON.
  • Your framework consumes the body before you see it (Express with a global express.json, for instance): register the raw parser for this route only, before the global one.

Alternative: the content API

If your site is static and rebuilds, pulling the content at build time may suit you better than receiving it. You have GET /public/v1/contents and GET /public/v1/contents/:slug, authenticated with a project API key you create in the panel.

Many people run both: the webhook as a rebuild trigger — ignoring the body — and the API as the source during the build.