Zum Inhalt springen
Guide

Your articles to your own endpoint.

The generic path for anything without a ready-made integration: makeseo sends the finished post, signed, to an address of your choice — you decide what happens with it.

This is a developer path. If you don't want to build an endpoint: the hosted blog needs no code at all.

You respond to us

For a post to count as published, we need its real address — from your response or from a stored pattern. Nothing is guessed.

Signed per Standard Webhooks

HMAC-SHA256 over the id, timestamp, and raw body. Ready-made verifiers exist for about ten languages — you don't have to write anything yourself.

Everything in the payload

Finished HTML, Markdown, meta, image, tags, the complete stylesheet, your brand color, and structured data. You render — so you get everything.

1 · Build the endpoint

A POST endpoint at a publicly reachable https address. No basic auth in front of it, no IP allowlist — our sender addresses aren't stable. The access protection is the signature.

2 · Verify the signature

Three headers, all lowercase. The scheme is Standard Webhooks — for most languages there's a ready-made library.

Headers
webhook-id:        msg_a3f1…      stable across all retries
webhook-timestamp: 1785312000     Unix SECONDS
webhook-signature: v1,K5oZfz…=   space-separated list

signed content:  {webhook-id}.{webhook-timestamp}.{raw body}
scheme:          HMAC-SHA256, result base64 (not hex)
Verification (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

const raw = await request.text();          // FIRST. Not request.json().
const id = request.headers.get("webhook-id")!;
const ts = request.headers.get("webhook-timestamp")!;
const header = request.headers.get("webhook-signature")!;

// Replay protection: the signature alone makes an intercepted request
// valid indefinitely.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("too old");

const material = secret.replace(/^whsec_/, "");
const expected = createHmac("sha256", Buffer.from(material, "base64url"))
  .update(`${id}.${ts}.${raw}`, "utf8")
  .digest("base64");

// Read it as a LIST — then your code stays valid across a key rotation, too.
const ok = header.split(" ").some((entry) => {
  const value = entry.split(",")[1];
  if (!value) return false;
  const a = Buffer.from(value), b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);   // never ===
});
Verification (Python)
import base64, hashlib, hmac, time

raw = request.get_data()                     # BYTES, not request.json
msg_id = request.headers["webhook-id"]
ts = request.headers["webhook-timestamp"]
header = request.headers["webhook-signature"]

if abs(time.time() - int(ts)) > 300:
    abort(400)

material = base64.urlsafe_b64decode(secret.removeprefix("whsec_") + "==")
signed = f"{msg_id}.{ts}.".encode() + raw
expected = base64.b64encode(hmac.new(material, signed, hashlib.sha256).digest()).decode()

ok = any(hmac.compare_digest(part.split(",", 1)[1], expected)
         for part in header.split(" ") if "," in part)

3 · Process the payload

POST body
{
  "version": 1,                        // only the envelope is versioned
  "event": "article.published",        // published | updated | test
  "id": "msg_a3f1…",
  "createdAt": "2026-08-04T06:12:03.114Z",

  "site":    { "projectId", "name", "domain", "language" },

  "article": {
    "id":               "9b02…",       // STABLE upsert key, not the slug
    "slug":             "kaltakquise-b2b",
    "title":            "…",
    "metaTitle":        "…",           // <title>
    "metaDescription":  "…",           // <meta name=description>
    "excerpt":          "…",
    "language":         "de",
    "contentHtml":      "<p>…</p>",    // finished, without <h1>, without wrapper
    "contentMarkdown":  "## …",
    "coverImage":       { "url", "alt" },
    "coverImageInBody": false,         // true = already inside contentHtml
    "tags":             [{ "label", "slug" }],
    "wordCount":        1840,
    "readingMinutes":   8,
    "publishedAt":      "…",
    "updatedAt":        "…",
    "externalId":       null           // your ID from last time
  },

  "theme":   { "className": "ms-article", "stylesheet", "stylesheetHash", "linkColor" },
  "brand":   { … },                    // optional, can be turned off
  "jsonLd":  { … },                    // BlogPosting (+ FAQPage)
  "attribution": { "required", "html", "text" },
  "rendering":   { "wrapper", "note" }
}

And here's how you respond:

Response — HTTP 200
{
  "url": "https://your-site.com/blog/kaltakquise-b2b",
  "id": "post_1234"
}

url is required for article.published (unless you've stored a pattern) and must sit below the base address you provided when connecting. id is optional and comes back with the next event for the same post. For article.test, a 2xx with no body is enough.

4 · Render

The whole wrapper
<style>{theme.stylesheet}</style>

<article class={theme.className}>
  <h1>{article.title}</h1>
  {!article.coverImageInBody && article.coverImage && (
    <img src={article.coverImage.url} alt={article.coverImage.alt} />
  )}
  <div innerHTML={article.contentHtml} />
  {attribution.required && <div innerHTML={attribution.html} />}
</article>

Three things that matter here — and that you only notice when they're missing:

  • Don't send contentHtml through another sanitizer. It's already sanitized and contains no JavaScript. Most sanitizers remove the id attributes of the headings — and the table of contents depends on them.
  • Respect coverImageInBody. Otherwise the cover image shows up twice or not at all.
  • theme.linkColor can be null. That means don't color anything — not use a default. Without a detected brand, the links stay black.

How delivery works

Success

A status code in the 2xx range — and for article.published additionally a valid address (from your response or from the pattern). A redirect counts as a failure: we don't follow any.

Time limit

10 seconds for the entire delivery. From your response we read at most 64 KB.

Retries — the real numbers

A temporary failure (timeout, 429, 5xx) is retried up to three times, no sooner than 30 minutes later — in practice at the next publishing run. After that the post is considered failed and is re-queued once after 24 hours, for a total of at most five attempts.

This is deliberately not the second-precise backoff curve from the Standard Webhooks recommendation. A two-hour outage is uncritical; an overnight outage costs that day's post.

Immediately final are: 401 and 403 (signature/access), 404, 410 Gone, any redirect, and a reported address that isn't on your website. On 401, 403, and 410 the connection is disconnected — these don't recover on their own.

Was dieser Weg nicht kann

  • Keine Baustein-Struktur. Du bekommst fertiges HTML und Markdown, nicht unsere interne Artikelstruktur. Sie hat kein Versionsfeld und ändert sich mit jeder Content-Verbesserung — ausgeliefert wäre sie ab dem ersten Kunden ein Vertrag, der uns beim Weiterentwickeln blockiert.
  • Kein Abruf. Es gibt auf diesem Weg kein GET /articles. Verpasste Beiträge sendest du im Dashboard erneut. Wer Pull braucht, nimmt den Next.js-Blog-Weg.
  • Kein Löschen bei dir. Ein zurückgezogener Beitrag muss auf deiner Seite von Hand entfernt werden.
  • Bilder liegen bei uns. Sie kommen als absolute Adressen. Wenn du sie dauerhaft brauchst, spiegle sie auf deiner Seite.
  • Der Endpunkt bekommt Vorrang. Hast du zusätzlich einen gehosteten Blog bei uns, erscheinen neue Beiträge ab dem Verbinden nur noch an deinem Endpunkt.

Wenn etwas klemmt

Jede Zustellung steht mit Statuscode, Dauer und Antwortauszug im Zustellprotokoll deiner Verbindung.

401 invalid signature — mine never matches
Nine times out of ten the body was parsed before verification. The signature is computed over the RAW bytes; JSON.parse plus JSON.stringify changes order and whitespace. In Express you need express.raw() before express.json().
“Your endpoint responded with a redirect”
We don't follow redirects — a 30x would be the way for a target to bypass our protection against internal addresses. Enter the final address, with or without a trailing slash, depending on what your server expects.
“No address reported, and no pattern is stored”
Respond with {"url":"https://…"} or store an address pattern in makeseo (https://your-site.com/blog/{slug}). Without either, the post stays scheduled — we don't mark it as published if we don't know its address.
“An address that isn't on your website”
The reported address must sit below the base address you provided when connecting. It ends up not only in the dashboard but also in the linking and analytics evaluation — an outside address there skews the metrics.
I'm getting the same post twice
We deliver at least once. The classic case: your endpoint processed it, but the response was lost — we see a failure and retry. Deduplicate by webhook-id (stable across all retries) and upsert by article.id, never by the slug.
After restarting my site, the table of contents is missing
Then contentHtml is running through an extra sanitizer. Most of them remove the id attributes of the headings — and the jump links depend on those. The HTML is already sanitized and contains no JavaScript; don't send it through again.
Kommst du nicht weiter?

Schick uns deine Fehlermeldung — wir schauen drauf.

Lieber ohne eigenen Endpunkt? Gehosteten Blog einrichten