開始整合
- 請管理員在「管理 → API 與整合」中建立您的應用程式。每個應用程式都有獨立的環境、內容範圍、頻道、語言、作者、到期時間與請求配額。
- 請將憑證儲存在您的伺服器上。正式環境使用 ft_live_ 憑證;沙盒環境使用 ft_test_。每次請求都傳送 Authorization: Bearer <credential>。
- 讀取 /capabilities 與 /content/taxonomies,以取得獲准的作者與主題 ID。使用 OpenAPI 中的結構描述準備您的文件。
- 建立草稿、提交審核,並接收發布事件。您的自動化流程可以準備實用內容,同時由編輯負責發布。
基礎 URL: https://www.formtruth.com/integrations/v1
內容與發布
每份外部草稿在發布前,都必須由工作人員核准其目前內容。聲明採用人工撰寫方式或設定自動發布,並不代表已獲得編輯核准。
文章、買家指南與常見問題共用同一種文件格式與版本記錄。請依照對應權限使用 /content/posts、/content/guides 或 /content/faqs。應用程式只能存取其擁有的內容;讀取私人草稿與讀取已發布網站的內容並不相同。
文件包含來源語言、實際作者、主題、來源,以及本地化的標題、網址識別名、摘要與結構化內文區塊。由 AI 協助或自動產生的文章必須說明讀者目標與原創貢獻。翻譯編輯不得變更產品事實、數字、圖片 ID、連結或結構尺寸。
每次寫入都需要 Idempotency-Key。更新、審核、發布與撤回也需要搭配引號括起的 ETag 使用 If-Match。編輯已審核的內容會使受影響的核准失效。變更來源後,受影響的翻譯會變為過時狀態,直到重新對齊並再次審核。
上傳的圖片在編輯檢查權利與品質之前都維持未核准狀態。已發布版本是不可變更的快照。撤回發布會將該版本從公開內容、資訊提供與搜尋結果中移除。
API 參考
以下路徑皆相對於基礎 URL。只要變更內容系列,指南與常見問題也適用相同的文章路由。下載 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} | 更新、輪替金鑰或停用傳送 |
目錄、訂單與庫存將保留至後續的 API 模組。目前尚未透過此 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。解析前請先驗證原始請求本文。
HMAC-SHA256(secret, timestamp + "." + event_id + "." + raw_body)
讀取 X-Formtruth-Timestamp、X-Formtruth-Event-Id 與 X-Formtruth-Signature。拒絕時間戳記超出五分鐘的請求。在 24 小時的金鑰輪替期間,簽章標頭可能包含多個以逗號分隔的 v1= 值;請接受您保留的金鑰中任何有效的簽章。
以原子方式儲存事件 ID 以進行去重、將工作加入佇列,並迅速回傳 2xx。傳送可能會重試,因此重複事件不得重複發布或重複收費。請將簽署密鑰與 API 憑證分開保存。
程式碼範例
使用 KnowledgeDocument 結構描述與實際獲准的分類 ID 準備 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.