Skip to content

Guide

Connect any website by webhook

Not on WordPress? No problem. BlogTend POSTs every finished article as signed JSON to an endpoint you control β€” any stack, any CMS, about twenty lines of code on your side.

What the webhook connector is

When an article finishes generating β€” or its scheduled moment arrives β€” BlogTend sends it to your endpoint as one JSON POST: title, full HTML, excerpt, SEO metadata, category and tag names, language, sources, and a public URL for the featured image. Your endpoint stores it however your site stores content, and everything else in BlogTend works exactly as it does for WordPress sites: automations, scheduling, drafts, the dashboard, Google indexing.

Every request is signed with a secret only you and BlogTend know, so your endpoint can prove the article really came from us and was not tampered with.

What your endpoint must do

  • Accept an HTTPS POST with a JSON body at a public URL.
  • Answer within 15 seconds with a 2xx status. Do slow work (image downloads, cache rebuilds) after answering, not before.
  • Answer every ping with a 2xx. A ping carries no article and must not write anything, so you may answer it before (or without) verifying it: that is what lets Test & connect pass while your secret is still being deployed.
  • Verify X-BlogTend-Signature on every article event (code below), and answer 401 when it does not match. We do not enforce this, but without it anyone who finds the URL could feed your site fake articles.
  • Treat article.id as the idempotency key: if a post with that id already exists, update it instead of creating a duplicate.
  • When you refuse a request, say why in the body (for example {"error": "invalid signature"}). We show the first 300 characters in your delivery log.

Connecting it

  1. Deploy your receiver endpoint (the example at the bottom is a complete one).
  2. In BlogTend, go to Sites β†’ Connect a site β†’ Webhook, enter a name and the endpoint URL, and press Next.
  3. We show the whsec_… signing secret. Copy it into your server's environment now and deploy: this screen is the only place it is shown.
  4. Press Test & connect. We send a ping signed with that secret; a 2xx saves the connection. If your endpoint refuses it, we show its status and its own error message so you can fix it and press Test & connect again.

Connecting the same URL again (to rename the site, or after an error) never changes the secret: the dialog shows the current one and the test ping is signed with it. To get a new secret, rotate it.

Headers we send

Every request, ping or article, carries these headers:

HeaderValueNotes
X-BlogTend-Signaturet=<unix seconds>,v1=<hex>[,v1=<hex>]HMAC-SHA256 signature. One v1 per live secret: two only during a rotation's 24-hour grace window, newest first.
X-BlogTend-Eventping | article.published | article.updatedSame as the event field in the body.
X-BlogTend-Deliverywhd_…Same as delivery_id in the body. New on every attempt, including retries.
X-BlogTend-Test1Only on pings (Test & connect, Re-verify, category refresh). A ping has no article and must not write anything.
Content-Typeapplication/jsonThe body is UTF-8 JSON.
User-AgentBlogTend-Webhook/1Was YoDon-Webhook/1 before the rename.
X-Yodon-Signature, X-Yodon-Event, X-Yodon-DeliverydeprecatedSent for receivers built before the rename, with the same values, except that X-Yodon-Signature always holds exactly one v1, made with the newest secret. New receivers should read the X-BlogTend-* headers.

The X-Yodon-* headers are deprecated. They keep working for receivers built before the rename, but only X-BlogTend-Signature carries both signatures during a rotation, so move to the new names when you next touch your receiver.

The payload, field by field

Three event types arrive, distinguished by the event field and the X-BlogTend-Event header: ping (connection test, no article), article.published (first delivery of an article) and article.updated (the same article again: after a retry you triggered, a draft being published, or a refresh of a post that is already live, in which case url names the live post to replace and id is the id you stored for it). The full shape:

{
  "event": "article.published",
  "delivery_id": "whd_5f0c9c1e-…",
  "site_id": "d2a41c3e-…",
  "sent_at": "2026-08-28T15:04:05.000Z",
  "article": {
    "id": "a81f6a02-…",
    "title": "How to Choose a Standing Desk",
    "slug": "how-to-choose-a-standing-desk",
    "status": "publish",
    "html": "<h2>…</h2><p>…</p>",
    "excerpt": "A practical buyer's guide…",
    "seo": {
      "title": "Standing Desk Buyer's Guide (2026)",
      "description": "Everything to check before…",
      "keyword": "standing desk"
    },
    "category": "Office Setup",
    "categories": ["Office Setup", "Buying Guides"],
    "tags": ["desks", "ergonomics"],
    "language": "en",
    "automation_id": null,
    "featured_image": {
      "url": "https://www.blogtend.com/api/images/…?sig=…",
      "mime": "image/webp"
    },
    "sources": [{ "title": "OSHA guidance", "url": "https://…" }],
    "published_at": "2026-08-28T15:04:05.000Z",
    "updated_at": "2026-08-28T15:04:05.000Z",
    "url": "https://example.com/blog/how-to-choose-a-standing-desk"
  }
}
  • article.status is the state we ask for: "publish" or "draft". Scheduled articles arrive at their scheduled time with "publish".
  • article.html is the complete article body. Image references inside it already point at public URLs.
  • category and categories are names, not ids β€” map them to your own taxonomy, or ignore them.
  • featured_image is null when the article has no image. The URL is signed and stable β€” fetch it any time.
  • delivery_id identifies this one attempt and changes on every retry; article.id identifies the article and never changes. Dedupe on article.id.
  • A ping body has only event, delivery_id, site_id and sent_at; no article.

Verifying the signature

Each request carries X-BlogTend-Signature: t=<unix seconds>,v1=<hex>. Each v1 value is an HMAC-SHA256 of the string "<t>.<raw body>" keyed with a whsec_ secret. Verify against the raw request body, before any JSON parsing, reject timestamps more than 5 minutes off, compare with a timing-safe function, and accept the request if any v1 matches your secret: during a rotation there are two.

Node.js

import crypto from "crypto";

// header: the X-BlogTend-Signature value, e.g. "t=1767225600,v1=ab12…,v1=cd34…".
// During a secret rotation it holds one v1 per live secret: accept if ANY matches.
export function verifyBlogTendSignature(secret, header, rawBody) {
  const parts = (header || "").split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  // Reject anything older than 5 minutes: replay protection.
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  );
  return parts
    .filter((p) => p.startsWith("v1="))
    .map((p) => Buffer.from(p.slice(3)))
    .some((sig) => sig.length === expected.length && crypto.timingSafeEqual(sig, expected));
}

PHP

function verify_blogtend_signature(string $secret, string $header, string $rawBody): bool {
    $t = null;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't') $t = $v;
        if ($k === 'v1') $sigs[] = $v;                      // one per live secret
    }
    if (!ctype_digit((string) $t) || abs(time() - (int) $t) > 300) return false; // replay window
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $sig) {
        if (hash_equals($expected, $sig)) return true;      // timing-safe
    }
    return false;
}

Python

import hashlib, hmac, time

def verify_blogtend_signature(secret: str, header: str, raw_body: bytes) -> bool:
    parts = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:   # replay window
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    # One v1 per live secret during a rotation: accept if any matches.
    return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")

An older receiver that reads X-Yodon-Signature with an exact single-v1 pattern keeps working: that header still has exactly one v1, made with the newest secret.

Rotating the secret

Lost the secret, or replacing it on a schedule? On the Sites page, open the webhook site's card and press Rotate secret. The new secret is shown once.

  • For the next 24 hours every request carries two signatures in X-BlogTend-Signature: one made with the new secret, one with the old. A receiver holding either secret verifies, so nothing fails while you update your server.
  • After 24 hours only the new secret signs. The card shows when the old one stops.
  • If the old secret leaked, tick "stop it now" when rotating: there is no grace period, and deliveries fail with 401 until your server has the new secret.
  • Receivers still reading the deprecated X-Yodon-Signature only see the new secret's signature, so for them the switch is immediate.

What to answer

Any 2xx counts as delivered; the body is optional and may be empty. Answer with JSON and BlogTend gets smarter:

{ "ok": true, "id": "123", "url": "https://example.com/blog/my-post" }
  • url β€” stored as the article's live link: the dashboard's View button opens it, and Google indexing submits it.
  • id β€” your post's identifier, stored so future article.updated events can be matched on your side too.
  • held β€” a short reason when you accepted the article but kept it off your site (an editorial queue, a house rule). BlogTend then shows it as a draft with that reason instead of a published post, and does not email "published". Answering status: "draft" does the same without a reason.

For a ping, any 2xx is enough too. The only field we read from a ping answer is categories: reply with { ok: true, categories: ["Guides", "News"] } (an array of up to 100 names) and those names appear as real category choices in BlogTend's creation forms. Refreshed on every Re-verify and on the category picker's refresh button. Omit the field and the AI simply proposes category names instead.

Failures, retries and duplicates

How BlogTend reads each answer to an article delivery:

Your answerWhat it means to usResent?
2xxDelivered. The body is optional; a JSON body is read as described in What to answer.Done.
3xxFailed. Redirects are not followed: connect the final URL instead.Not resent immediately.
401 / 403Failed: shown as "signature rejected". The secret on your server does not match. Paste it again or rotate it.Not resent immediately.
503Failed: shown as "not configured yet", the usual answer of a receiver whose secret is not set.Resent once, straight away.
Other 4xxFailed: you understood the request and refused it.Not resent immediately.
Other 5xxFailed: your endpoint crashed or is down.Resent once, straight away.
No answer within 15 s, or a network errorFailed: shown as "could not reach your endpoint".Resent once, straight away.
  • Articles BlogTend publishes on its own (automations and scheduled posts): if a delivery still fails, the whole article is tried again on the next two runs, about a minute apart, whatever the answer was. After the third failed try BlogTend stops, keeps the finished article, and emails you the reason. Publish it again once your endpoint is fixed.
  • Articles you publish by hand: no automatic retry. The reason appears on the article straight away; press Publish again, or Retry in Recent deliveries.
  • An article is never lost: a failed one stays in BlogTend with the reason, ready to publish again.
  • Where to look: on the Sites page, a webhook site's card has Recent deliveries. Each one shows the status code, what it usually means, and the first 300 characters of your error message; failed ones have a Retry button. The first kilobyte of every response is stored.
  • Because of retries your endpoint may see the same article twice, each time with a new delivery_id. This is why upserting by article.id matters.

A complete example receiver

A Next.js App Router route that verifies, dedupes and stores. Swap savePost for your own persistence and it's production-ready:

// app/api/articles/webhook/route.ts β€” a complete Next.js receiver
import { NextResponse } from "next/server";
import { verifyBlogTendSignature } from "./verify"; // the Node.js function above

const SECRET = process.env.BLOGTEND_WEBHOOK_SECRET; // the whsec_… shown when you connected

export async function POST(req: Request) {
  const body = await req.text(); // raw body β€” verify BEFORE parsing
  let payload;
  try {
    payload = JSON.parse(body);
  } catch {
    return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
  }

  // A ping carries no article and writes nothing, so it is answered without
  // verifying: Test & connect then passes even before SECRET is deployed.
  // Return nothing private here.
  if (payload.event === "ping") {
    // Optional: advertise your categories so BlogTend's forms can offer them.
    return NextResponse.json({ ok: true, categories: await listMyCategories() });
  }

  // Every content event must be verified. Clear messages here show up in
  // BlogTend's delivery log, so "wrong secret" never looks like "down".
  if (!SECRET) {
    return NextResponse.json({ error: "receiver not configured: BLOGTEND_WEBHOOK_SECRET is not set" }, { status: 503 });
  }
  const header =
    req.headers.get("x-blogtend-signature") ?? req.headers.get("x-yodon-signature") ?? "";
  if (!verifyBlogTendSignature(SECRET, header, body)) {
    return NextResponse.json({ error: "invalid signature: check BLOGTEND_WEBHOOK_SECRET" }, { status: 401 });
  }

  const a = payload.article;
  // Upsert by a.id β€” retries and updates must not create duplicates.
  const post = await savePost({
    externalId: a.id,
    slug: a.slug,
    title: a.title,
    html: a.html,
    excerpt: a.excerpt,
    metaTitle: a.seo.title,
    metaDescription: a.seo.description,
    tags: a.tags,
    category: a.category,
    imageUrl: a.featured_image?.url ?? null,
    published: a.status === "publish",
  });

  // Answer with the live URL so the BlogTend dashboard links to the post.
  return NextResponse.json({ ok: true, id: post.id, url: post.url });
}

The same logic ports to any framework in a few minutes β€” the FAQ below covers the common platform questions.

Questions

Which platforms does this work with?
Any stack that can receive an HTTPS POST: Next.js, Laravel, Django, Rails, Express, a Cloudflare Worker, a no-code tool like Make or n8n, a custom CMS β€” if it has a URL, it can receive articles.
I lost the signing secret. Where do I find it again?
Nowhere: we store it encrypted and only show it while you connect. Use Rotate secret on the site card in Sites. You get a new secret, and the old one keeps signing alongside it for 24 hours, so deliveries keep working while you update your server.
Do scheduled articles work?
Yes. Scheduling stays on BlogTend's side: at the scheduled moment we send the article with status "publish". Your endpoint never has to implement scheduling.
What about drafts?
An article sent as a draft arrives with status "draft". What that means on your site is your call β€” most receivers store the post unpublished. When you later hit Publish in BlogTend, the same article arrives again as an article.updated event with status "publish".
How do images arrive?
The featured image comes as a signed public URL on our servers (featured_image.url), and the article HTML references the same URL. Download it and host it yourself, or serve it straight from ours β€” both work.
Is the payload ever retried? Will I see duplicates?
Yes. A failed delivery is retried, and every retry has a new delivery_id, so your endpoint can see the same article more than once. Treat article.id as the idempotency key: update the existing post instead of creating a second one, and duplicates become impossible.
Does SEO metadata come through?
Yes β€” seo.title, seo.description and seo.keyword ride along with every article, plus the excerpt, tags, category names and language. Use what your platform supports and ignore the rest.

Publishing to WordPress instead? Read the WordPress guide

Your stack, our articles

Connect an endpoint once and every article β€” manual, bulk or automated β€” flows straight into your site.