开始对接
- 请管理员在后台「API 与对接」创建应用,为其设置环境、接口权限、渠道、语种、作者、有效期和请求配额。
- 将凭证保存在服务端。正式环境使用 ft_live_,沙盒使用 ft_test_。请求头使用 Authorization: Bearer <credential>。
- 先调用 /capabilities 和 /content/taxonomies,获取获授权作者和分类编号;再按 OpenAPI 的文档结构准备内容。
- 创建草稿并提交审核,接收发布事件。自动化系统负责准备有用内容,编辑负责最终审核与发布。
基础地址: https://www.formtruth.com/integrations/v1
内容与发布
所有外部草稿须由站内编辑审核当前内容后才能发布。自行声明人工创作或设置自动发布,都不能代替编辑审批。
文章、采购指南与 FAQ 共用内容结构和版本记录,分别使用 /content/posts、/content/guides 和 /content/faqs 及对应权限。应用仅能访问自己创建的内容;接口草稿与网站公开内容分开读取。
文档包含源语种、真实作者、主题、来源,以及各语种标题、链接名、摘要和结构化正文。AI 辅助或自动创作文章须说明读者目标及原创价值。翻译不得改变商品事实、数字、素材编号、链接及内容结构。
每次写入需要 Idempotency-Key。修改、提审、发布及撤回还需传入带引号的 ETag 作为 If-Match。修改已审核内容会使对应审批失效;源文变化后,译文须重新核对并审核。
上传图片须经编辑检查使用权与质量后才可发布。公开发布使用不可变快照;撤回后会同步移出公开内容、订阅源和搜索发现目录。
接口目录
下表路径均相对于基础地址。采购指南与 FAQ 使用相同路由结构,替换内容类型即可。OpenAPI 提供完整请求结构、权限和响应定义。
| 方法 | 路径 | 用途 |
|---|
GET | /capabilities | 当前应用权限与配额 |
GET / POST | /content/posts | 读取本应用文章或创建草稿 |
GET / PATCH | /content/posts/{id} | 获取草稿与 ETag,或保存新版本 |
PUT | /content/posts/by-external-id/{externalId} | 按外部系统唯一编号创建或更新草稿 |
POST | /content/posts/{id}/submit-review | 提交编辑审核 |
POST | /content/posts/{id}/publications | 发布已审核语种,或设置发布时间 |
DELETE | /content/posts/{id}/publications/{releaseId} | 记录原因并撤回发布 |
GET | /content/taxonomies | 可用主题与获授权作者 |
POST | /content/imports | 批量导入最多 50 条草稿,逐项查看结果 |
POST | /assets/uploads | 上传图片,进入质量与使用权审核 |
POST | /assets/{id}/complete | 查看已保存素材状态 |
GET | /jobs/{id} | 读取本应用任务结果 |
GET / POST | /webhooks/subscriptions | 查看或注册本应用 HTTPS 事件接收地址 |
POST | /webhooks/subscriptions/{id} | 修改、轮换签名密钥或停用 |
商品、订单和库存会沿用同一接口中心规划,目前尚未在 v1 网关开放。
错误与重试
使用未压缩的 UTF-8 JSON。普通写入请求上限 2 MiB,批量导入 8 MiB,编码后图片上传 18 MiB;请求体须在十秒内送达。413 表示超出大小,415 表示格式不支持,408 表示上传超时。
错误会返回 error.code、error.message、适用时的字段问题、retryable 和 request_id。请保留 X-Request-Id 便于定位。401 是凭证问题,403 是权限问题,412 是版本变化,422 是内容校验未通过,428 是缺少必要请求条件。
429 请遵循 Retry-After。503 或网络中断时采用有限次数退避重试,保持原请求编号与内容不变。不同内容使用新编号。409 应先核对原操作,避免盲目重复。批量任务通过 /jobs/{id} 返回逐项结果。
Webhook 验签
使用公网 HTTPS 443 接收地址。事件包括 content.review_required、content.published、content.withdrawn、content.publication_failed 与 import.completed。先对原始请求体验签,再解析 JSON。
HMAC-SHA256(secret, timestamp + "." + event_id + "." + raw_body)
读取 X-Formtruth-Timestamp、X-Formtruth-Event-Id 和 X-Formtruth-Signature。拒绝超过五分钟的时间戳。密钥轮换的 24 小时过渡期可能包含多个逗号分隔的 v1= 签名,匹配持有密钥的有效签名即可。
用持久存储原子记录事件编号以去重,将处理加入队列并及时返回 2xx。投递可能重试,重复事件不得重复发布或扣费。签名密钥与 API 凭证分开保存。
代码示例
使用 KnowledgeDocument 结构及真实获授权分类编号准备 document.json,并在服务端安全设置 FORMTRUTH_API_TOKEN。示例只创建草稿,发布仍需经过审核流程。
import { readFile } from "node:fs/promises"
import { randomUUID } from "node:crypto"
const base = process.env.FORMTRUTH_URL || "https://www.formtruth.com"
const token = process.env.FORMTRUTH_API_TOKEN
if (!token) throw new Error("Set FORMTRUTH_API_TOKEN in a secure environment")
const document = JSON.parse(await readFile("document.json", "utf8"))
// Persist the key before calling. Reuse it only for an identical retry.
const key = process.env.FORMTRUTH_REQUEST_KEY || randomUUID()
console.log("Request key:", key)
const response = await fetch(base + "/integrations/v1/content/posts", {
method: "POST",
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify({ channel: "b2b", external_id: document.locales[document.source_locale].slug, document }),
})
const result = await response.json()
if (!response.ok) throw new Error(JSON.stringify(result.error))
console.log("Draft:", result.entry.id, "ETag:", response.headers.get("etag"))
// Creating a draft does not publish it. Submit for editorial review separately.
import json, os, uuid, urllib.request, urllib.error
base = os.environ.get("FORMTRUTH_URL", "https://www.formtruth.com")
token = os.environ["FORMTRUTH_API_TOKEN"]
with open("document.json", encoding="utf-8") as source:
document = json.load(source)
# Persist this key before sending; reuse for an identical retry.
key = os.environ.get("FORMTRUTH_REQUEST_KEY", str(uuid.uuid4()))
print("Request key:", key)
payload = {"channel": "b2b", "external_id": document["locales"][document["source_locale"]]["slug"], "document": document}
request = urllib.request.Request(base + "/integrations/v1/content/posts",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), method="POST",
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json", "Idempotency-Key": key})
try:
with urllib.request.urlopen(request, timeout=45) as response:
result = json.load(response)
print("Draft:", result["entry"]["id"], "ETag:", response.headers.get("ETag"))
except urllib.error.HTTPError as error:
raise RuntimeError(error.read().decode("utf-8")) from error
# Creating a draft does not publish it. Submit for editorial review separately.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyWebhook(rawBody, headers, secret, now = Date.now()) {
const timestamp = headers.get("x-formtruth-timestamp") || ""
const eventId = headers.get("x-formtruth-event-id") || ""
const signatures = (headers.get("x-formtruth-signature") || "").split(",")
if (!/^[0-9]{10}$/.test(timestamp) || !eventId || Math.abs(now - Number(timestamp) * 1000) > 300000) return false
const expected = createHmac("sha256", secret).update(timestamp + "." + eventId + ".").update(rawBody).digest()
return signatures.some(value => {
const part = value.trim()
if (!/^v1=[a-f0-9]{64}$/.test(part)) return false
const actual = Buffer.from(part.slice(3), "hex")
return actual.length === expected.length && timingSafeEqual(actual, expected)
})
}
// Verify the raw body BEFORE JSON parsing. Atomically record eventId in durable
// storage to deduplicate retries; enqueue accepted work, then return HTTP 2xx.
// During rotation both v1 signatures are sent for 24 hours.
import hashlib, hmac, re, time
def verify_webhook(raw_body, headers, secret, now=None):
timestamp = headers.get("x-formtruth-timestamp", "")
event_id = headers.get("x-formtruth-event-id", "")
signatures = headers.get("x-formtruth-signature", "").split(",")
current = time.time() if now is None else now
if not re.fullmatch(r"[0-9]{10}", timestamp) or not event_id or abs(current - int(timestamp)) > 300:
return False
signed = (timestamp + "." + event_id + ".").encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return any(re.fullmatch(r"v1=[a-f0-9]{64}", part.strip()) and hmac.compare_digest(part.strip()[3:], expected) for part in signatures)
# Normalize header names to lowercase. Verify raw bytes before parsing JSON.
# Atomically persist event_id for deduplication, enqueue work, then return 2xx.