Start an integration
- Ask an administrator to create your application in Admin → API & integrations. Each application has a separate environment, content scopes, channels, languages, authors, expiry and request quota.
- Store the credential on your server. Production uses ft_live_ credentials; sandbox uses ft_test_. Send Authorization: Bearer <credential> with each request.
- Read /capabilities and /content/taxonomies to obtain the permitted author and topic IDs. Use the schema in OpenAPI to prepare your document.
- Create a draft, submit it for review and listen for publication events. Your automation can prepare useful content while an editor remains responsible for release.
Base URL: https://www.formtruth.com/integrations/v1
Content & publishing
Every external draft requires staff approval of its current content before release. A declared human authoring method or automated publication setting does not grant editorial approval.
Posts, buyer guides and FAQs share one document format and version history. Use /content/posts, /content/guides or /content/faqs with the corresponding permission. Applications can access only content they own; reading a private draft is different from reading the published website.
Documents contain a source language, real author, topics, sources and localized titles, slugs, summaries and structured body blocks. AI-assisted or automated articles must identify their reader goal and original contribution. Translation edits cannot change product facts, numbers, image IDs, links or structural dimensions.
Each write needs an Idempotency-Key. Updates, review, publication and withdrawal also need If-Match with the quoted ETag. Editing reviewed content invalidates the affected approval. Changing the source makes affected translations stale until aligned and reviewed again.
Uploaded images remain unapproved until an editor checks rights and quality. Published releases are immutable snapshots. Withdrawal removes the release from public content, feeds and search discovery.
API reference
Paths below are relative to the base URL. The same article routes apply to guides and FAQs by changing the content family. Download OpenAPI for full request schemas, required scopes and responses.
| Method | Path | Purpose |
|---|
GET | /capabilities | Your effective permissions and limits |
GET / POST | /content/posts | List your articles / create a draft |
GET / PATCH | /content/posts/{id} | Read the draft and ETag / save a revision |
PUT | /content/posts/by-external-id/{externalId} | Create or update a draft by your stable external ID |
POST | /content/posts/{id}/submit-review | Submit for editorial review |
POST | /content/posts/{id}/publications | Publish approved languages or schedule a release |
DELETE | /content/posts/{id}/publications/{releaseId} | Withdraw a release with a reason |
GET | /content/taxonomies | Active topics and permitted authors |
POST | /content/imports | Queue up to 50 drafts and inspect item results |
POST | /assets/uploads | Upload an image for rights and quality review |
POST | /assets/{id}/complete | Read the persisted upload status |
GET | /jobs/{id} | Read your import results |
GET / POST | /webhooks/subscriptions | List or register your HTTPS event receivers |
POST | /webhooks/subscriptions/{id} | Update, rotate the key or disable delivery |
Catalog, orders and inventory are reserved for later API modules. They are not available through this v1 gateway yet.
Errors & retries
Send uncompressed UTF-8 JSON. Request body limits are 2 MiB for ordinary writes, 8 MiB for imports and 18 MiB for encoded image uploads; the body must arrive within ten seconds. A 413 means the body is too large, 415 means an unsupported format and 408 means an upload timed out.
Errors return error.code, error.message, field details when applicable, retryable and request_id. Keep X-Request-Id for support. A 401 means a credential problem; 403 means permission; 412 means a changed version; 422 means invalid or unready content; 428 means a missing precondition.
For 429, respect Retry-After. For a temporary 503 or network interruption, retry with bounded backoff and the SAME key and input. A different payload needs a new key. On 409, reconcile the previous operation instead of repeating blindly. Imports return per-item results through /jobs/{id}.
Signed webhooks
Use a public HTTPS receiver on port 443. Events include content.review_required, content.published, content.withdrawn, content.publication_failed and import.completed. Verify the raw request body before parsing it.
HMAC-SHA256(secret, timestamp + "." + event_id + "." + raw_body)
Read X-Formtruth-Timestamp, X-Formtruth-Event-Id and X-Formtruth-Signature. Reject timestamps outside five minutes. The signature header may contain multiple comma-separated v1= values during the 24-hour key rotation window; accept any valid signature for your retained key.
Atomically save the event ID for deduplication, enqueue the work and return 2xx promptly. Delivery can be retried, so a duplicate event must not publish or charge twice. Keep the signing secret separate from the API credential.
Code examples
Prepare document.json using the KnowledgeDocument schema and real permitted taxonomy IDs. Set FORMTRUTH_API_TOKEN securely on your server. These examples create a draft; publication still requires the review workflow.
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.