RTFC API

Checking a video, a stream, or a file

Always probe first

bash
curl -sX POST $RTFC/media/probe -H "X-Auth-Token: $TOKEN" \
  -H 'Content-Type: application/json' -d '{"url": "https://…"}'

Anonymous, synchronous, creates nothing, costs nothing. YouTube, TikTok, Instagram and X posts with a video are supported. For an X post, post_text carries the post's own words: the analysis checks them along with the video. What the post says the video shows is weighed against the clip, which is never treated as a source: a clip can be edited or generated, so a clip that contradicts reliable sources makes the claim unverifiable.

GET /v1/debates (both debates and items) and GET /v1/debates/{id} expose the same nullable media and lifecycle metadata: media_platform, media_external_id, media_duration_s, media_is_canonical, media_is_public, media_title, media_description, media_uploader, media_uploader_id, media_creator, media_thumbnail_url, ingest_state, ingest_resume_offset_ms, research_paused, analysis_started_at. These are stored values; missing metadata stays null. analysis_started_at is the processing start, not completion or publication time; date remains the debate date. Use source_url to open the original. The list does not expose raw staging paths or signed stream URLs. Visibility and owner/access-grant checks are unchanged.

Paginated GET /v1/debates rows include created_by_id and nullable created_by_username for each accessible analysis, including shared analyses. The owner name is joined in the list query; clients do not need a detail request to display it. This does not grant access to the owner’s profile or other analyses.

GET /v1/debates rows and GET /v1/debates/{id} expose nullable, additive analysis_status and eta_seconds. Exactly one status applies, in precedence order: source_unavailable (stored ingest failure or Transcript ingest stage failed), missing_speaker (an effective diarized voice has no assignment), delayed (still processing after its historical run budget expires, no ETA), processing (unfinished ingest, deferred detection or pending research), review (results ready with unreviewed claims), then ready. Explicitly unidentified voices satisfy the speaker gate. There is no analysis verdict, confidence or summary. eta_seconds is an integer only while processing, otherwise null; clients round up to minutes. Estimates sum remaining known media minutes × median ingest seconds per minute for the stored origin, and pending claims × median research seconds per claim. Rates use up to 500 completed analyses started in the last 30 days, cached for five minutes; an unknown origin falls back to the global median. Missing required history, unknown media duration or deferred extraction without a claim count yields null. History contains instance-wide operational durations only; each analysis input remains access-scoped. The existing status list filter still filters stored ingest_state.

The estimate is bounded by the current run’s historical total budget (all known media minutes and claim occurrences), measured from analysis_started_at or creation when absent. Elapsed time is deducted from that budget; once exhausted, eta_seconds is null and analysis_status becomes delayed (Delayed). Unknown estimates remain processing without ETA. The internal library_status=processing filter includes delayed analyses; library_status=delayed isolates them. Refreshing Redis medians does not restart the run clock.

The dashboard displays delayed as “Delayed”, without an ETA, in Home, Library, the analysis header and linked social/TV rows. It reuses processing status elements with an existing warning token. Processing library filters and totals include delayed analyses; the dedicated delayed filter isolates them.

GET /v1/debates and GET /v1/debates/{id} add processing_progress: {steps, percentage}. The four ordered states are done, now or pending. Stages are weighted download 5, transcription 20, detection 25 and verification 50 points. Detection and verification overlap the earlier stages, so detection is scaled by the share of audio transcribed and verification by (finished claims / detected claims) projected onto the share of audio already analysed. Unknown fractions count zero, the value never decreases while an analysis runs, the total is rounded down, and 100 requires all stages to finish. Confirmed whole-transcript detector completion is recorded independently of ingest completion. POST /v1/session/stop preserves partial content and records an incomplete ingest position; queued research may still finish. Existing owner/editor authorization and pause/resume endpoints apply. When a stage ends with technical failures that persisted through every retry (extraction windows for detection, claims for verification), processing_progress.failures lists {stage, count} for detection and verification. Such a stage is settled rather than in progress, so the analysis no longer reads as processing forever; the failures are reported instead.

<!-- Naming state is independent; completion includes claim-backed legacy rules. --> Stages are sequential: a later stage stays pending until earlier stages finish. A teardown timestamp is not detection completion. Missing speakers are a naming state: only an enabled speaker gate actually withholding segments (pending/deferred detection) holds detection for naming. With the gate off, confirmed detection and completed research can reach 100% while the status remains missing_speaker, with no processing section. Speaker-attribution-held claims are excluded from processing; other claims require a selected final verdict and every expected/manual method barrier. Zero claims cannot complete verification before confirmed detection. Legacy version-0/NULL rows with finished ingest, a legacy detection_finished_at and at least one claim count detection as done, unless detection is deferred. While research is pending, Verification is current at floor(75 + 25 × final verdicts / unheld claims); final verdicts require the expected/manual method barrier. Once research finishes, progress is 100%. Without a marker, legacy completion still requires no held claims and every selected final verdict/method barrier satisfied. Zero legacy claims do not qualify. Existing rows retain version 0; new inserts default to version 1, and resets set version 1 before work begins. New/restarted runs require a current confirmed timestamp and stay at 50% after ingest until it arrives, even with zero claims. Direct-claim analyses (nonempty claims, NULL origin, no media or transcript, no ingest or active detection) count the first three stages as done: progress is floor(75 + 25 × final verdicts / unheld claims), and final claims preserve review/ready status without a processing section. Empty new rows do not qualify. Transcript-only brand scans with detection disabled finish at 100% once ingest finishes, even with zero claims; the separate brand report is outside this progress.

t = measured transcription fraction; d = measured detection fraction; f = final verdicts / unheld claims, with all expected/manual methods complete. Fractions are clamped to 0–1; unknown fractions contribute zero. Every done step contributes 25 points, and the current step contributes 25 × its measured fraction. Progress is capped at 99 until every step is done. This includes legacy, version-1 and direct-claim analyses.

Preparation / Transcription / Detection / Verification%
now / pending / pending / pending0
done / pending / pending / pending25
done / now / pending / pendingfloor(25 + 25 × t)
done / done / now / pendingfloor(50 + 25 × d)
done / done / done / nowfloor(75 + 25 × f)
done / done / done / done100

review_progress: {reviewed,total} counts reviewed claim occurrences at their current answer revision, including dismissed occurrences. A correction or new research result invalidates review; audit history remains. sort=review on GET /v1/debates orders remaining reviews descending, then creation time descending and ID ascending.

"Public" means an anonymous fetch succeeds. Every probe is made without cookies, deliberately - a video that only downloads because our servers happen to be signed in somewhere is not public, and treating it as such would leak private content between accounts.

reject_reasonMeaning
unsupported_platformNot a platform we can fetch
not_a_single_videoA playlist, channel or profile
unavailableDeleted, or the id is wrong
privateNot publicly viewable
needs_authRequires a login
geo_blockedNot available from our region
live_in_progressStill streaming - try again when it ends

Empty means it is usable.

Ingest

bash
curl -sX POST $RTFC/debates/$DEBATE/upload-media \
  -H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"url": "https://…", "language": "auto", "detector_mode": "batch"}'

For YouTube, the video's own captions are used when available - no transcription cost, exact timestamps - falling back to audio + speech-to-text automatically when there are none in the language you asked for.

detector_mode:

Audio/video files and live audio

Debates with a captured media source (video, replay, podcast, broadcast or uploaded file), including reused analyses, are fixed to that source. Uploads, new recording/client sessions, client transcript segments and new manual claims return 409. Create a new debate to add content. Existing claims can still be reviewed and rechecked.

POST /v1/debates/{id}/upload-audio takes a file (multipart file, plus optional whisper_provider, language, no_diarize). no_diarize skips speaker separation - much faster, at the cost of not knowing who said what. Without whisper_provider the speech-to-text model is chosen for you, with an automatic fallback to another provider if it is unavailable.

You can also upload a video with an audio track using this endpoint or the dashboard’s Import tab (file picker or drag and drop). Supported video containers: MP4, WebM, MOV, MKV, AVI, M4V, MPEG and MPG. The listener transcribes the audio and checks spoken claims; video images are not analyzed. Audio formats MP3, M4A, WAV, OGG, FLAC, AAC and Opus remain supported.

Uploaded files (upload-audio, upload-media/blob) may be up to 2 GB. The file is streamed to storage as it arrives, so a large upload takes as long as your connection needs. A larger file is refused with 413 and {"detail": {"code": "file_too_large", "max_bytes": 2147483648}} before any processing starts; nothing is analysed and nothing is counted against your hours.

bash
curl -sX POST $RTFC/debates/$DEBATE/upload-audio \
  -H "X-Auth-Token: $TOKEN" -F "file=@video.mov"

For live audio, POST /v1/session/start spawns a listener, then POST /v1/session/audio-ingest-token gives you a short-lived WebSocket URL to stream into.

That token call returns 409 while the listener is still starting. That is expected and retryable - poll it, do not treat it as an error.

Positions in the video

Each claim carries media_offset_ms: where in the media it was said, measured from the start of the media rather than wall-clock. That is what you need to sync verdicts to a player's current time.

It is null when the position could not be determined - never 0 as a stand-in, so a falsy check is safe.

Reuse

Submitting a video always gives you your own analysis, with detection and verification run fresh by the current pipeline. When an earlier analysis of the same public video exists, its transcript, speaker corrections and speaker names are copied instead of transcribing again; the response then carries "transcript_copied": true and no transcription hours are used. Only an analysis RTFC has declared shared is joined instead ("matched_existing": true, see the tutorial): you are granted access to that debate and its original content source stays fixed. DELETE /v1/debates/{id} on a joined analysis only revokes your access grant and responds 200 with {"deleted": false, "access_revoked": true}.

Speaking time

GET /v1/debates/{id}/stats includes speaking_seconds, a map of speaker names to estimated speaking seconds. It uses the full stored transcript, applies segment/turn overrides and speaker assignments, and excludes ads and jingles. Unassigned voices use Speaker <label>, unidentified voices use __UNIDENTIFIED_SPEAKER__, and unlabelled speech uses ?. Durations use media offsets (or timestamps when no media offsets exist); gaps over two minutes and the final segment are estimated from text length at 15 characters per second, capped at two minutes. Speakers with less than one estimated second are omitted. These are estimates, not exact voice activity measurements. The dashboard shows them alongside per-speaker claim counts in Global Stats, refreshing every ten seconds while visible.

Retry an unavailable source

bash
curl -sX POST $RTFC/debates/$DEBATE/retry-source \
  -H "X-Auth-Token: $TOKEN"

POST /v1/debates/{debate_id}/retry-source requires authentication and owner or editor access, and checks the owning account’s quota (402 when exceeded). It returns {"debate_id":"…","status":"started"} and restarts ingest on the same analysis. It returns 409 unless ingest_state is failed, or if the original source cannot be fetched again. Supported sources are podcasts, TF1, france.tv, RMC+, YouTube, TikTok, Instagram and X; uploads and live recordings cannot be retried this way. Refresh the debate to follow Processing.

Podcast feeds can list an episode before its audio file is live. A 404 for an episode published or first seen within six hours (or whose age is unknown) is retried automatically for up to 30 minutes, with backoff capped at 120 seconds. Operators can configure the window with PODCAST_NOT_YET_PUBLISHED_WINDOW_S (default 1800). Older episodes and other permanent client errors fail immediately.