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

MethodPathWhoSendGet back
POST/auth/startnone + Turnstile{email, turnstileToken}204. Emails a 6-digit code and a sign-in link, both valid 15 minutes.
POST/auth/verifynone{email, code} or {token}{account, sessionToken?} and a session cookie
POST/auth/logoutsession204
GET/accountany{id, name, email, handle, balanceCredits, limits, isInternal, relationship, freeGrantAvailable}
PATCH/accountsession{name?, handle?}account
GET / POST/api-keyssessionPOST {name, mode, scopes?}POST returns {id, key} once
DELETE/api-keys/:idsession204
POST/uploadsany{filename, bytes, contentType, sha256?}{uploadId, partSize, parts:[{n, url}]}: upload each part with PUT
POST/uploads/:id/completeany{parts:[{n, etag}]}{uploadId, state:"complete"}
GET/propertiesany{items:[Property]}
POST/propertiesany{kind, url}{property, instructions}
POST/properties/:id/verifyany{method, code?}{property}
POST/quotesany{source:{url} or {uploadId}, versionOf?, type?}{durationSec, credits, priceBasis, balanceCredits, title?, showName?, platform}
POST/piecespieces:write{source, title?, description?, kind?, publishDate?, ownership, publishWhenDone?, youtubeUrl?, maxExtraCredits?, webhookUrl?, consentTextVersion}202 {piece, check}
GET/piecesany?state=&cursor={items:[Piece], nextCursor}
GET/pieces/:publicIdownerPiece with checks
PATCH/pieces/:publicIdowner{title?, description?}Piece
POST/pieces/:publicId/publishowner (session){consentTextVersion}Piece
POST/pieces/:publicId/withdrawowner (session){reason?}Piece
POST/pieces/:publicId/checksowner{type:"new_cut", source, note?} or {type:"recheck"}202 {check}
GET/pieces/:publicId/checks/:nownerCheck state, progress, minutes left, credits, score, outcome, awaitingApproval
GET/pieces/:publicId/checks/:n/resultownerFull private result (409 not_finished)
POST/pieces/:publicId/checks/:n/approveowner{approve: true or false}Continue (extra credits) or cancel (full refund)
POST/pieces/:publicId/checks/:n/cancelownerAllowed until checking starts; full refund
POST/pieces/:publicId/checks/:n/publish-nowownerPublishes a later check before its 72 hour review window ends
POST/pieces/:publicId/appealsowner{n, claims:[{k, urls, note, reason}]}201 {appeal}
GET/pieces/:publicId/appealsowner{items:[Appeal]}
GET / POST/webhookswebhooks:writePOST {url, events?}POST returns {id, secret} once
DELETE/webhooks/:idwebhooks:write204. POST /webhooks/:id/test sends a test event
GET/pricingnonePrices, packs and rules
POST/billing/checkoutsession{packId}{url} to a secure checkout page
GET/billing/ledgerbilling:read{balanceCredits, items}
GET/public/piecesnone?q=&kind=&outcome=&rechecked=&creator=&cursor={items, nextCursor}
GET/public/pieces/:publicIdnoneTrust record
GET/public/pieces/:publicId/checks/:nnoneOne public check
POST/public/reportsnone + Turnstile{publicId, n, k, url, note?, email?}201
GET/public/statsnonePublic 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"}}
CodeMeaning
unauthorizedNo valid key or session.
forbiddenYour key does not have the needed scope.
not_foundNo such thing, or it is not yours.
invalid_requestSomething in the request is missing or wrong. The message says what.
insufficient_creditsBuy minutes, then try again.
ownership_requiredConfirm you own the show or channel first.
unsupported_mediaWe cannot check this kind of link or file.
too_longLonger than 240 minutes.
duplicate_fileYou already submitted this exact file. The body includes its publicId.
rate_limitedToo many requests. Wait for the time in Retry-After.
conflictThe request clashes with the current state.
idempotency_mismatchThe same Idempotency-Key was used with a different body.
not_finishedThe check is not finished yet.
provider_unavailableA 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.