通过 API 发布
通过签名 API 请求将文章发布到你的网站或 CMS。
Papigrows 向您托管的 HTTPS 接收服务发送已签名的 POST 请求。接收服务保存并发布文章,然后返回文章的公开网址。
配置 API 发布连接您的发布接收端
创建一个在您网站上保存并发布文章的接收端。在 CMS 设置中添加接收端和共享密钥。测试并保存会发送数据为空的 connection.test;请返回 HTTP 200 和 ok: true。只有所有者和管理员可以更改连接。
请使用公开的 HTTPS 接收端,不要包含查询参数、片段或嵌入的凭据。
已生成随机密钥。你可以替换为自己设置的 32 至 256 个字符的密钥。请将其复制到接收端并保存在服务器上。
{
"version": 1,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true }测试并保存期间,请让接收端同时接受旧密钥和新密钥。Papigrows 会继续使用已保存的密钥,直到新连接通过测试并保存。进行中的投递完成后,再停用旧密钥。
请求标头
连接测试和文章投递都会包含这些标头。标头名称不区分大小写。
| 标头名称 | 示例值 | 含义 |
|---|---|---|
Content-Type | application/json | JSON 格式的请求体。 |
Accept | application/json | 预期返回 JSON 格式的确认响应。 |
X-Papigrows-Event-Id | <event-id> | 与信封中的 id 一致。 |
X-Papigrows-Timestamp | <unix-seconds> | 本次尝试的 Unix 时间戳,单位为秒。 |
X-Papigrows-Signature | v1=<hex-hmac-sha256> | v1= 后接时间戳 + '.' + 完整原始请求体的十六进制 HMAC-SHA256。 |
Idempotency-Key | <event-id> | 重试时保持相同的稳定信封 id。 |
验证每个请求
解析 JSON 前,验证以时间戳、一个点和原始请求正文计算的 v1 HMAC-SHA256 签名。使用恒定时间比较,拒绝超过五分钟或未来的时间戳,并将密钥保存在服务器端。HTTPS 和签名不能代替重复请求防护。
请求示例
POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 1,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}请求体字段
这些字段描述 article.upsert。对于 connection.test,data 是空对象。信封协议版本与文章内容版本相互独立。
| 字段名称 | 值类型 | 包含条件 | 含义 |
|---|---|---|---|
version | integer | 始终包含 | 协议版本:原始格式为 1;明确升级接收端后使用 2。 |
event | string | 始终包含 | 事件类型:article.upsert 或 connection.test。 |
id | string | 始终包含 | 与两个身份标头一致的稳定投递 ID。请保存它以防重复写入。 |
data | object | 始终包含 | 事件数据。connection.test 的数据为空对象。 |
data.id | string | 始终包含 | 该语言变体的永久 ID。请以此为键创建或更新文章。 |
data.version | integer | 始终包含 | 已保存的内容修订号,为非负整数。请拒绝旧修订。 |
data.title | string | 始终包含 | 文章标题。 |
data.html | string | 始终包含 | 要发布的 HTML,包含可用的 hreflang 链接。 |
data.slug | string | 有值时包含 | 建议的网址 slug。后续编辑时请保持公开网址稳定。 |
data.excerpt | string | 有值时包含 | 文章摘要片段,有值时提供。 |
data.coverImageUrl | string | 有值时包含 | 封面图片网址,有值时提供。 |
data.locale | string | 有值时包含 | 此文章变体的语言代码。 |
data.hreflangAlternates | array | 有值时包含 | 其他已发布的语言变体,有值时提供。 |
data.hreflangAlternates[].locale | string | 每个语言变体 | 链接变体的语言代码。 |
data.hreflangAlternates[].url | string | 每个语言变体 | 该已发布变体的公开网址。 |
版本 2 与兼容性
版本 2 包含纯文本、工作区和受众标识符、摘要、栏目和标签。接收端必须在连接测试中明确确认支持版本 2。现有接收端将继续使用版本 1,直到你选择其他版本、通过测试并保存。
{
"version": 2,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true, "version": 2 }POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 2,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"groupId": "<permanent-article-group-id>",
"workspaceId": "<owning-workspace-id>",
"primaryAudienceId": "<saved-audience-id>",
"businessAreaId": "<saved-business-area-id>",
"text": "...",
"summary": "...",
"section": "Advice",
"tags": ["Families"],
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}版本 2 保留原有文章字段,并添加以下必填字段。没有保存受众定位的旧文章,其受众标识符仍为 null。
| 字段名称 | 值类型 | 含义 |
|---|---|---|
data.groupId | UUID | 各语言版本共用的稳定文章组标识符。 |
data.workspaceId | UUID | 文章所属工作区的已保存标识符。 |
data.primaryAudienceId | UUID | null | 已保存的主要受众标识符;未定位受众的旧文章为 null。 |
data.businessAreaId | UUID | null | 已保存的业务领域标识符;缺失时为 null。 |
data.text | string | 根据清理后的文章 HTML 生成的纯文本,并非原始 Markdown;不会抓取链接内容。 |
data.summary | string | null | 已保存的文章摘要;缺失时为 null。 |
data.section | string | null | 本次发布时记录的栏目;没有栏目时为 null。 |
data.tags | string[] | 本次发布时记录的标签;空数组表示没有标签。 |
公开内容订阅源
启用 Papigrows API 连接后才能公开已发布文章。请明确请求版本 2;不指定版本时仍返回原始格式。私人工作区和未发布文章无法访问。
/api/content/feed/<workspace-slug>?version=2&locale=en&limit=20每篇文章包含已保存的标识符、内容版本、纯文本和摘要。栏目和标签仅来自发布时记录的目标配置;缺少此记录时,标签为 null。每次游标请求都应保留 version=2 和 locale。
在公开订阅源中,无法转换时 text 为 null。超过 1 MiB 时 textUnavailable 为 body_too_large,转换出错时为 conversion_failed,正常时为 null。HTML 和分页保持完整。
确认发布
文章真正公开后,请返回 HTTP 200 或 201、相同的 ID、状态 published 以及公开的 HTTPS 网址。队列确认、HTTP 202 或 204、重定向、草稿或无效响应均不算发布成功。请勿在保存和发布完成前确认成功。
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "<permanent-article-id>",
"status": "published",
"url": "https://example.com/blog/example"
}投递和重试
article.upsert 使用 data.id 作为永久键创建或更新一种语言的文章。请将 data.version、事件 ID 和内容变更原子化保存。重复事件返回已保存的确认,旧版本绝不能覆盖新内容。最多尝试三次,每次限时五秒。网络故障、HTTP 429 和 HTTP 5xx 会在 250 毫秒和 1 秒后重试。正文和事件 ID 保持不变,每次更新时间戳和签名。
投递请求体上限为 1 MiB,JSON 响应上限为 16 KiB。解析之前,请先限制原始请求体的大小。
此集成的范围
仅包括文章及从 Papigrows 再次发布的编辑。不包括主题事件、删除事件、后台投递队列或读取远端修改。邮件回复编辑无法安全更新 webhook 目标;请在 Papigrows 中编辑并发布。现有套餐、质量和自动发布设置仍然适用。发生结果不确定的故障后,手动重试前请检查接收端;系统不会无限重试。
签名验证示例
此 Node.js 辅助函数仅验证真实性。您的接收端还必须限制请求大小、验证事件结构、在持久存储中处理重复事件和版本、发布文章,并返回下面的确认响应。
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers.get("x-papigrows-timestamp") ?? "";
const signature = headers.get("x-papigrows-signature") ?? "";
if (!/^\d{10}$/.test(timestamp)) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age < 0 || age > 300) return false;
if (!/^v1=[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest();
const received = Buffer.from(signature.slice(3), "hex");
if (received.length !== expected.length
|| !timingSafeEqual(received, expected)) return false;
try {
const event = JSON.parse(rawBody.toString());
return typeof event.id === "string"
&& /^[a-f0-9]{64}$/.test(event.id)
&& headers.get("x-papigrows-event-id") === event.id
&& headers.get("idempotency-key") === event.id;
} catch { return false; }
}