For developers
The FactUnion API
Submit episodes and videos from your own systems, follow each check, and read public results. Everything you can do on the site, you can do with the API.
Overview
Base address: https://api.factunion.com/v1. Requests and answers are JSON. Times are UTC in ISO 8601. Money is in US cents. Public reads need no key and allow any website to call them.
Keys and sign-in
Create a key on your API keys page and send it as Authorization: Bearer fct_live_.... A key is shown once. Keys have scopes: pieces:write, pieces:read, webhooks:write, billing:read.
Every POST accepts an Idempotency-Key header for 24 hours. Sending the same key with a different body returns 409 idempotency_mismatch. Lists use ?limit=50&cursor=.
Endpoints
| Method | Path | Who | Send | Get back |
|---|---|---|---|---|
POST | /auth/start | none + Turnstile | {email, turnstileToken} | 204. Emails a 6-digit code and a sign-in link, both valid 15 minutes. |
POST | /auth/verify | none | {email, code} or {token} | {account, sessionToken?} and a session cookie |
POST | /auth/logout | session | 204 | |
GET | /account | any | {id, name, email, handle, balanceCredits, limits, isInternal, relationship, freeGrantAvailable} | |
PATCH | /account | session | {name?, handle?} | account |
GET / POST | /api-keys | session | POST {name, mode, scopes?} | POST returns {id, key} once |
DELETE | /api-keys/:id | session | 204 | |
POST | /uploads | any | {filename, bytes, contentType, sha256?} | {uploadId, partSize, parts:[{n, url}]}: upload each part with PUT |
POST | /uploads/:id/complete | any | {parts:[{n, etag}]} | {uploadId, state:"complete"} |
GET | /properties | any | {items:[Property]} | |
POST | /properties | any | {kind, url} | {property, instructions} |
POST | /properties/:id/verify | any | {method, code?} | {property} |
POST | /quotes | any | {source:{url} or {uploadId}, versionOf?, type?} | {durationSec, credits, priceBasis, balanceCredits, title?, showName?, platform} |
POST | /pieces | pieces:write | {source, title?, description?, kind?, publishDate?, ownership, publishWhenDone?, youtubeUrl?, maxExtraCredits?, webhookUrl?, consentTextVersion} | 202 {piece, check} |
GET | /pieces | any | ?state=&cursor= | {items:[Piece], nextCursor} |
GET | /pieces/:publicId | owner | Piece with checks | |
PATCH | /pieces/:publicId | owner | {title?, description?} | Piece |
POST | /pieces/:publicId/publish | owner (session) | {consentTextVersion} | Piece |
POST | /pieces/:publicId/withdraw | owner (session) | {reason?} | Piece |
POST | /pieces/:publicId/checks | owner | {type:"new_cut", source, note?} or {type:"recheck"} | 202 {check} |
GET | /pieces/:publicId/checks/:n | owner | Check state, progress, minutes left, credits, score, outcome, awaitingApproval | |
GET | /pieces/:publicId/checks/:n/result | owner | Full private result (409 not_finished) | |
POST | /pieces/:publicId/checks/:n/approve | owner | {approve: true or false} | Continue (extra credits) or cancel (full refund) |
POST | /pieces/:publicId/checks/:n/cancel | owner | Allowed until checking starts; full refund | |
POST | /pieces/:publicId/checks/:n/publish-now | owner | Publishes a later check before its 72 hour review window ends | |
POST | /pieces/:publicId/appeals | owner | {n, claims:[{k, urls, note, reason}]} | 201 {appeal} |
GET | /pieces/:publicId/appeals | owner | {items:[Appeal]} | |
GET / POST | /webhooks | webhooks:write | POST {url, events?} | POST returns {id, secret} once |
DELETE | /webhooks/:id | webhooks:write | 204. POST /webhooks/:id/test sends a test event | |
GET | /pricing | none | Prices, packs and rules | |
POST | /billing/checkout | session | {packId} | {url} to a secure checkout page |
GET | /billing/ledger | billing:read | {balanceCredits, items} | |
GET | /public/pieces | none | ?q=&kind=&outcome=&rechecked=&creator=&cursor= | {items, nextCursor} |
GET | /public/pieces/:publicId | none | Trust record | |
GET | /public/pieces/:publicId/checks/:n | none | One public check | |
POST | /public/reports | none + Turnstile | {publicId, n, k, url, note?, email?} | 201 |
GET | /public/stats | none | Public counts |
Piece is {publicId, title, kind, showName, platform, sourceUrl, submissionType, ownerVerified, pageState, relationship, latestN, createdAt, trustPageUrl}. Public results follow the formats published on each public page as /v/<id>.json.
YouTube: upload the video file. You may add youtubeUrl; matching it to your channel is coming later. Spotify links are not supported yet: use the podcast link (RSS) or the Apple Podcasts link.
A first check
curl
# 1. Ask the price first. Nothing is spent.
curl https://api.factunion.com/v1/quotes \
-H "Authorization: Bearer $FACTUNION_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source":{"url":"https://example.com/feed.xml#episode-12"}}'
# 2. Submit the episode.
curl https://api.factunion.com/v1/pieces \
-H "Authorization: Bearer $FACTUNION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: episode-12" \
-d '{"source":{"url":"https://example.com/feed.xml#episode-12"},
"ownership":"owner","publishWhenDone":true,
"consentTextVersion":"owner_warranty-2026-10-13"}'
# 3. Follow progress.
curl https://api.factunion.com/v1/pieces/7K3M9QX2/checks/1 \
-H "Authorization: Bearer $FACTUNION_API_KEY"
JavaScript
const api = (path, body) => fetch('https://api.factunion.com/v1' + path, {
method: body ? 'POST' : 'GET',
headers: { Authorization: 'Bearer ' + process.env.FACTUNION_API_KEY, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error.message); return j; });
const source = { url: 'https://example.com/feed.xml#episode-12' };
const quote = await api('/quotes', { source });
console.log('This check uses', quote.credits, 'minutes');
const { piece, check } = await api('/pieces', {
source, ownership: 'owner', consentTextVersion: 'owner_warranty-2026-10-13',
});
console.log('Public page:', piece.trustPageUrl);
Python
import os, requests
API = "https://api.factunion.com/v1"
H = {"Authorization": "Bearer " + os.environ["FACTUNION_API_KEY"]}
source = {"url": "https://example.com/feed.xml#episode-12"}
quote = requests.post(f"{API}/quotes", json={"source": source}, headers=H).json()
print("This check uses", quote["credits"], "minutes")
r = requests.post(f"{API}/pieces", headers=H, json={
"source": source, "ownership": "owner",
"consentTextVersion": "owner_warranty-2026-10-13",
})
r.raise_for_status()
print("Public page:", r.json()["piece"]["trustPageUrl"])
If an episode has more claims than its minutes include, the check pauses with awaitingApproval until you approve the extra minutes. To approve up front, send maxExtraCredits with the episode.
Webhooks
Events: check.queued, check.awaiting_approval, check.completed, check.failed, check.published, appeal.completed, property.verified, property.lapsed, balance.low.
{"id":"evt_...","type":"check.completed","created":"2026-10-13T21:40:00Z",
"data":{"publicId":"7K3M9QX2","n":2,"score":94,"outcome":"verified",
"trustPage":"https://factunion.com/v/7K3M9QX2/2"}}
Each delivery carries FactUnion-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of t + "." + body with your webhook secret. We wait 10 seconds, then retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Addresses must use https.
import crypto from 'node:crypto';
// header: FactUnion-Signature: t=1760390213,v1=5f2c...
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const mac = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(parts.v1));
}
Errors
{"error":{"code":"insufficient_credits","message":"You need 22 more minutes for this check.","doc":"https://factunion.com/api#errors"}}
| Code | Meaning |
|---|---|
unauthorized | No valid key or session. |
forbidden | Your key does not have the needed scope. |
not_found | No such thing, or it is not yours. |
invalid_request | Something in the request is missing or wrong. The message says what. |
insufficient_credits | Buy minutes, then try again. |
ownership_required | Confirm you own the show or channel first. |
unsupported_media | We cannot check this kind of link or file. |
too_long | Longer than 240 minutes. |
duplicate_file | You already submitted this exact file. The body includes its publicId. |
rate_limited | Too many requests. Wait for the time in Retry-After. |
conflict | The request clashes with the current state. |
idempotency_mismatch | The same Idempotency-Key was used with a different body. |
not_finished | The check is not finished yet. |
provider_unavailable | A provider we depend on is down. We retry automatically. |
Limits
Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Too many requests return 429 with Retry-After. Episodes and videos up to 240 minutes and 4 GB. Up to 3 checks running at once per account. New accounts can check up to 600 minutes a day in their first 30 days.
Pricing
The API counts minutes as credits: 1 credit is 1 minute, rounded up, at least 3 per episode or video. Checks of a corrected version cost half. Packs: Starter 100 minutes for $30, Creator 500 minutes for $125, Studio 2000 minutes for $400. Read the prices as JSON at GET /v1/pricing, or see pricing.