指南
通过 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 个字符.
连接设置
- 部署您的接收端点 (底部示例提供了完整实现).
- 在 BlogTend 中, 前往网站 → 连接网站 → Webhook, 输入名称和端点 URL, 然后点击下一步.
- 这里显示 whsec_… 签名密钥. 请立即将其复制到服务器的环境变量中并部署: 此密钥仅在此页面显示.
- 点击测试 & 连接. 我们会发送使用该密钥签名的探测请求; 收到 2xx 状态码后即保存连接. 如果您的端点拒绝该请求, 我们会显示其状态码及返回的错误消息, 方便您修复问题后再次点击测试 & 连接.
再次连接同一 URL (为网站重命名, 或在出错后重新连接) 不会更改密钥: 对话框会显示当前密钥, 测试请求也会使用该密钥签名. 如需新密钥, 请轮换密钥.
我们发送的请求头
每个请求, 无论是连通性测试还是文章推送, 都携带以下请求头:
| 请求头 | 值 | 备注 |
|---|---|---|
| X-BlogTend-Signature | t=<unix seconds>,v1=<hex>[,v1=<hex>] | HMAC-SHA256 签名. 每个有效密钥对应一个 v1: 仅在密钥轮换的 24 小时宽限期内有两个, 最新的排在前面. |
| X-BlogTend-Event | ping | article.published | article.updated | 与正文中的 event 字段相同. |
| X-BlogTend-Delivery | whd_… | 与请求体中的 delivery_id 相同. 每次尝试都会生成新值, 包括重试. |
| X-BlogTend-Test | 1 | 仅用于探测请求 (测试 & 连接, 重新验证, 刷新分类). 探测请求不包含文章, 且不得写入任何内容. |
| Content-Type | application/json | 正文为 UTF-8 编码的 JSON. |
| User-Agent | BlogTend-Webhook/1 | 更名前为 YoDon-Webhook/1. |
| X-Yodon-Signature, X-Yodon-Event, X-Yodon-Delivery | deprecated | 发送给更名前构建的接收端, 值保持一致, 但 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 });
}同样的逻辑只需几分钟即可移植到任何框架 — 下方的常见问题解答涵盖了各平台的常见问题.
问题
支持哪些平台?
我丢失了签名密钥. 在哪里可以重新找到它?
定时发布的文章能正常发布吗?
草稿怎么办?
图片如何传送?
载荷会重试发送吗? 我会收到重复数据吗?
SEO 元数据会传递过来吗?
想改为发布到 WordPress? 阅读 WordPress 指南