跳转到内容

指南

通过 Webhook 连接任意网站

不用 WordPress? 没问题. BlogTend 会通过 POST 将每篇完成的文章以签名 JSON 格式发送到你控制的端点 — 任何技术栈、任何 CMS 均可, 你只需编写约二十行代码.

什么是 Webhook 连接器

文章生成完成 — 或到达预定时间 — 时, BlogTend 会通过一次 JSON POST 请求将其发送到您的端点: 标题, 完整 HTML, 摘要, SEO 元数据, 分类和标签名称, 语言, 来源, 以及特色图片的公开 URL. 您的端点按网站自身的内容存储方式保存文章, 而 BlogTend 中的其他功能与用于 WordPress 网站时完全一致: 自动化任务, 定时发布, 草稿, 控制面板, Google 索引.

每个请求都使用仅您和 BlogTend 知道的密钥进行签名, 因此您的端点可以验证文章确实来自我们且未被篡改.

您的端点必须实现的功能

  • 在公开 URL 上接收带有 JSON 请求体的 HTTPS POST 请求.
  • 在 15 秒内返回 2xx 状态码. 耗时操作 (图片下载、缓存重建) 应在响应后执行, 而不是在响应前.
  • 对每个 ping 请求都返回 2xx 状态码. ping 请求不携带文章, 也绝不能执行任何写入操作, 因此你可以在验证前 (或不验证) 就响应: 这样即使密钥仍在部署中, 测试 & 连接也能通过.
  • 对每个文章事件验证 X-BlogTend-Signature (代码见下方), 不匹配时返回 401. 我们不强制要求这样做, 但如果不验证, 任何找到该 URL 的人都可以向你的网站发送伪造的文章.
  • 将 article.id 作为幂等键: 如果已存在具有该 ID 的文章, 则更新该文章而不是创建重复文章.
  • 拒绝请求时, 请在响应正文中说明原因 (例如 {"error": "签名无效"}). 我们会在您的投递日志中显示前 300 个字符.

连接设置

  1. 部署您的接收端点 (底部示例提供了完整实现).
  2. 在 BlogTend 中, 前往网站 → 连接网站 → Webhook, 输入名称和端点 URL, 然后点击下一步.
  3. 这里显示 whsec_… 签名密钥. 请立即将其复制到服务器的环境变量中并部署: 此密钥仅在此页面显示.
  4. 点击测试 & 连接. 我们会发送使用该密钥签名的探测请求; 收到 2xx 状态码后即保存连接. 如果您的端点拒绝该请求, 我们会显示其状态码及返回的错误消息, 方便您修复问题后再次点击测试 & 连接.

再次连接同一 URL (为网站重命名, 或在出错后重新连接) 不会更改密钥: 对话框会显示当前密钥, 测试请求也会使用该密钥签名. 如需新密钥, 请轮换密钥.

我们发送的请求头

每个请求, 无论是连通性测试还是文章推送, 都携带以下请求头:

请求头值备注
X-BlogTend-Signaturet=<unix seconds>,v1=<hex>[,v1=<hex>]HMAC-SHA256 签名. 每个有效密钥对应一个 v1: 仅在密钥轮换的 24 小时宽限期内有两个, 最新的排在前面.
X-BlogTend-Eventping | article.published | article.updated与正文中的 event 字段相同.
X-BlogTend-Deliverywhd_…与请求体中的 delivery_id 相同. 每次尝试都会生成新值, 包括重试.
X-BlogTend-Test1仅用于探测请求 (测试 & 连接, 重新验证, 刷新分类). 探测请求不包含文章, 且不得写入任何内容.
Content-Typeapplication/json正文为 UTF-8 编码的 JSON.
User-AgentBlogTend-Webhook/1更名前为 YoDon-Webhook/1.
X-Yodon-Signature, X-Yodon-Event, X-Yodon-Deliverydeprecated发送给更名前构建的接收端, 值保持一致, 但 X-Yodon-Signature 始终只包含一个用最新密钥生成的 v1. 新接收端应读取 X-BlogTend-* 请求头.

X-Yodon-* 请求头已弃用. 它们仍适用于更名前构建的接收端, 但在密钥轮换期间, 只有 X-BlogTend-Signature 同时包含两个签名, 因此请在下次修改接收端时改用新名称.

载荷, 逐字段说明

接收端会收到三种事件, 可通过 event 字段和 X-BlogTend-Event 请求头区分: ping (连接测试, 不含文章), article.published (首次投递文章) 和 article.updated (再次投递同一篇文章: 在你触发重试后, 发布草稿时, 或更新已上线的文章时, 此时 url 指定要替换的已上线文章, id 则是你为该文章保存的 id). 完整结构:

{
  "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",
    "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 是我们要求的状态: "publish" 或 "draft". 定时发布的文章会在预定时间以 "publish" 状态送达.
  • article.html 是完整的文章正文. 其中的图片引用已指向公开可访问的 URL
  • category 和 categories 是名称, 不是 ID — 请将它们映射到您自己的分类体系, 或忽略它们.
  • 文章没有图片时 featured_image 为 null. URL 已签名且保持不变 — 可随时获取.
  • delivery_id 标识本次投递尝试, 每次重试都会改变; article.id 标识文章, 始终保持不变. 请根据 article.id 去重.
  • ping 请求体仅包含 event, delivery_id, site_id 和 sent_at; 不包含文章.

验证签名

每个请求都携带 X-BlogTend-Signature: t=<unix seconds>,v1=<hex>. 每个 v1 值都是使用 whsec_ 密钥对字符串 "<t>.<raw body>" 计算得到的 HMAC-SHA256. 在进行任何 JSON 解析之前, 请基于原始请求体进行验证, 拒绝时间戳偏差超过 5 分钟的请求, 使用恒定时间比较函数进行比较, 只要任一 v1 与您的密钥匹配就接受请求: 密钥轮换期间会有两个签名.

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")

使用精确匹配单个 v1 的模式读取 X-Yodon-Signature 的旧版接收端仍可正常工作: 该请求头仍然只有一个 v1, 由最新密钥生成.

轮换密钥

密钥丢失了, 或需要定期更换? 在网站页面打开 Webhook 网站的卡片, 然后点击轮换密钥. 新密钥仅显示一次.

  • 在接下来的 24 小时内, 每个请求的 X-BlogTend-Signature 中都会携带两个签名: 一个使用新密钥生成, 另一个使用旧密钥生成. 接收端持有任一密钥即可通过验证, 因此您更新服务器期间不会出现请求失败.
  • 24 小时后仅使用新密钥进行签名. 卡片会显示旧密钥的停用时间.
  • 如果旧密钥已泄露, 请在轮换时勾选 "立即停用": 此操作没有宽限期, 在服务器配置新密钥之前, 投递都会失败并返回 401.
  • 仍在读取已弃用的 X-Yodon-Signature 的接收端只能看到新密钥生成的签名,因此对这些接收端而言,切换会立即生效.

回答内容

任何 2xx 响应都视为投递成功; 响应正文是可选的, 可以为空. 以 JSON 格式响应, 就能让 BlogTend 更智能:

{ "ok": true, "id": "123", "url": "https://example.com/blog/my-post" }
  • url — 保存为文章发布后的链接: 控制面板中的查看按钮会打开此链接, Google 索引功能也会提交此链接.
  • id — 您的文章标识符, 保存后可让您在自己的系统中匹配后续的 article.updated 事件.
  • held — 接收文章但未将其发布到网站时的简短原因 (进入编辑审核队列, 受内部规则限制). BlogTend 随后会将其显示为草稿并附上该原因, 而非已发布的文章, 也不会发送 "已发布" 邮件通知. 返回 status: "draft" 也有同样效果, 但不附带原因

对于 ping 请求, 返回任意 2xx 状态码也足够. 我们从 ping 响应中读取的唯一字段是 categories: 返回 { ok: true, categories: ["Guides", "News"] } (包含最多 100 个名称的数组), 这些名称就会作为实际可选的分类显示在 BlogTend 的创建表单中. 每次重新验证或点击分类选择器的刷新按钮时都会刷新. 如果省略该字段, AI 就只会建议分类名称.

失败, 重试和重复请求

BlogTend 如何解读文章交付后的各类响应:

您的回答这对我们的意义是否已重发?
2xx已送达. 响应正文可选; JSON 正文会按照“如何响应”中的说明读取.已完成.
3xx失败. 不会跟随重定向: 请改为连接最终 URL.不会立即重新发送.
401 / 403失败: 显示为 "签名被拒绝". 您服务器上的密钥不匹配. 请重新粘贴或轮换密钥.不会立即重新发送.
503失败: 显示为 "尚未配置", 这是接收端未设置密钥时的常见响应.已立即重发一次.
其他 4xx失败: 您的服务器已理解请求但拒绝处理.不会立即重新发送.
其他 5xx失败: 您的端点已崩溃或不可用.已立即重发一次.
15 秒内未响应, 或发生网络错误失败: 显示为 "无法连接到您的端点".已立即重发一次.
  • 对于 BlogTend 自动发布的文章 (自动化任务和定时文章): 如果投递仍然失败, 无论收到什么响应, 都会在接下来的两次运行中重新尝试投递整篇文章, 间隔约一分钟. 第三次尝试失败后, BlogTend 会停止尝试, 保留已完成的文章, 并通过电子邮件告知你原因. 修复端点后, 即可重新发布.
  • 手动发布的文章: 不会自动重试. 失败原因会立即显示在文章上; 请再次点击发布, 或在最近的投递记录中点击重试.
  • 文章绝不会丢失: 发布失败的文章会保留在 BlogTend 中, 并附上失败原因, 随时可以重新发布.
  • 查看位置: 在网站页面上, Webhook 网站的卡片中有最近投递记录. 每条记录都会显示状态码、通常含义以及错误消息的前 300 个字符; 投递失败的记录有重试按钮. 每次响应的前 1 KB 内容都会被保存.
  • 由于重试, 您的端点可能会收到同一篇文章两次, 每次的 delivery_id 都不同. 因此, 按 article.id 执行更新或插入操作非常重要.

一个完整的接收端示例

一个用于验证、去重和存储的 Next.js App Router 路由. 将 savePost 替换为你自己的持久化逻辑, 即可用于生产环境:

// 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 });
}

同样的逻辑只需几分钟即可移植到任何框架 — 下方的常见问题解答涵盖了各平台的常见问题.

问题

支持哪些平台?
任何能接收 HTTPS POST 请求的技术栈都可以: Next.js, Laravel, Django, Rails, Express, Cloudflare Worker, Make 或 n8n 等无代码工具, 自定义 CMS — 只要有 URL, 就能接收文章.
我丢失了签名密钥. 在哪里可以重新找到它?
任何地方都无法查看: 我们会加密存储密钥, 仅在您连接时显示. 请使用“网站”中网站卡片上的“轮换密钥”功能. 您将获得一个新密钥, 旧密钥会在接下来的24小时内与新密钥同时用于签名, 确保您更新服务器期间仍能正常投递.
定时发布的文章能正常发布吗?
可以. 定时发布仍由 BlogTend 负责: 到达预定时间时, 我们会发送状态为 "publish" 的文章. 您的端点无需实现定时发布功能.
草稿怎么办?
以草稿形式发送的文章到达时, 状态为 "draft". 该状态在你的网站上如何处理由你决定 — 大多数接收端会将文章保存为未发布状态. 当你之后在 BlogTend 中点击发布时, 同一篇文章会作为 article.updated 事件再次发送, 状态为 "publish".
图片如何传送?
特色图片通过我们服务器上带签名的公开 URL 提供 (featured_image.url), 文章 HTML 也引用同一 URL. 您可以下载图片并自行托管, 也可以直接从我们的服务器加载 — 两种方式都可行.
载荷会重试发送吗? 我会收到重复数据吗?
是的. 投递失败后会重试, 每次重试都会生成新的 delivery_id, 因此您的端点可能会多次收到同一篇文章. 请将 article.id 作为幂等键: 更新已有文章, 而不是创建另一篇, 这样就不会出现重复文章.
SEO 元数据会传递过来吗?
是的 — 每篇文章都会附带 seo.title、seo.description 和 seo.keyword, 以及摘要、标签、分类名称和语言. 使用您的平台支持的字段即可, 其余可忽略.

想改为发布到 WordPress? 阅读 WordPress 指南

您的技术栈, 我们的文章

只需连接端点一次, 每篇文章 — 无论是手动、批量还是自动发布 — 都会直接发送到您的网站.