# Civly Intelligence API - Integration Guide for AI Agents This document is written so an AI coding assistant (Claude, etc.) can build, operate, and debug a Civly partner integration from this single file. Base URL (production): https://app.civly.ai/api/v1/partner OpenAPI spec (partner endpoints only): GET {base}/openapi.json Human docs: GET {base}/docs No API key yet? Request access: GET {base}/request-access (form) or POST {base}/access-request ## Authentication Every request needs a partner API key (format: cvly_pk_...), sent either way (the one exception is the signed clip play/thumbnail links in Product 9, which carry no key): Authorization: Bearer cvly_pk_YOURKEY X-API-Key: cvly_pk_YOURKEY Keys are issued manually by Civly with a per-key access level: which products you may call, separate monthly submission quotas for reports and for social analysis, and a concurrent-jobs cap. Keys are shown once at issuance and can be revoked instantly. Never log or commit keys. ## Required on EVERY submission: the attestation field "attestation": "subject-is-candidate-or-public-figure" Send that exact string. It affirms the research subject is a candidate for public office or a public figure and the research is for lawful political purposes. Requests without it are rejected with 422. Every submission is recorded in an audit log. ## Strongly recommended: Idempotency-Key header Submissions are expensive and asynchronous. Send a unique client-chosen value per logical submission so retries (timeouts, crashes) return the ORIGINAL job instead of starting and billing a duplicate: Idempotency-Key: order-7f3a-attempt A replay responds with the same job and "idempotent_replay": true. The key is scoped per product - the same value on /reports and /social-analysis names two different operations. ## Product 1: Research reports (Big Book) Full opposition-research document on a candidate/public figure. Takes 5-10 minutes to generate. Flow: submit -> poll (or webhook) -> download. The raw research data is downloadable the moment the report completes; the polished document is released separately after a human quality review (see /result). Submit: POST {base}/reports { "subject_name": "Jane Example", "subject_type": "candidate", // candidate | donor | organization "office": "US Senate", "state": "OH", "party": "Republican", "twitter_handle": ["janeexample"], // optional; omitted handles are auto-discovered "attestation": "subject-is-candidate-or-public-figure" } -> 202 {"job_id": "1234", "product": "big_book", "status": "running", "idempotent_replay": false} A submit for a subject that already has a report in flight for your company returns 409 naming the running job_id. Wait for it to finish (or fail) before submitting the same subject again; completed and failed reports never block a new one. Distinct subjects and different companies are never affected. Poll status (every 30-60s; do not poll faster): GET {base}/reports/{job_id} -> {"job_id": "1234", "status": "running", "progress": "Generating content... (12/45)"} status is exactly one of: queued | running | completed | failed. Fetch result (only after status == completed): GET {base}/reports/{job_id}/result -> {"document_status": "in_review", "document_url": null, "raw_data_url": "...", "expires_in_seconds": 3600} The two deliverables are released on different clocks. raw_data_url (the full research dataset, JSON) is ready the moment status hits completed. The polished document passes a human quality review before release: document_url stays null and document_status reads "in_review" until an analyst approves it, after which the same endpoint returns document_status "available" with a live document_url. Review happens on a human timescale (hours, not seconds); re-check /result occasionally rather than tight-polling. document_status "unavailable" means no document is coming for this report - contact Civly. Links are presigned and valid for 1 hour; call the endpoint again for a fresh one. Calling before completion returns 409 - keep polling. ## Product 2: Social media analysis Multi-platform analysis (twitter, youtube, instagram, tiktok, reddit, substack, bluesky, facebook, truthsocial, rumble, twitch, linkedin, and web archives; plus mastodon, which runs only when explicitly requested in "platforms"). Usually completes in a few minutes. The web-archives platform sweeps the Internet Archive for archived copies of the subject's profile pages - deleted, renamed or private accounts - and returns snapshot timelines with links. For a handle YOU supply (never one only guessed from the person's name), it also returns the recovered text of individual archived posts found under that profile, but only posts whose archived page names the same account. Each is dated to the real post date when the post id or the archived page states it, and otherwise to the archive capture date, and says which. Guessed profiles, and handles supplied without a platform, still return snapshot timelines only, never post content. POST {base}/social-analysis { "person_name": "Jane Example", "platforms": ["twitter", "youtube", "reddit"], "attestation": "subject-is-candidate-or-public-figure" } -> 202 {"job_id": "", "status": "queued", "platforms_queued": ["twitter","youtube","reddit"], "platforms_skipped": []} The 202 returns within a couple of seconds: per-platform dispatch runs asynchronously right after the response, so you never wait on it (or time out against it). platforms_queued in the 202 lists the platforms ACCEPTED for dispatch; platforms_skipped lists any not accepted (for example, platforms that don't support topic-mode search). The status endpoint reflects per-platform dispatch within a few seconds of the 202 - a poll arriving in that window may briefly show an empty platforms list; keep polling. Billing is recorded per platform actually dispatched, so a platform that fails to queue is never billed. "platforms" is optional. Omit it and the analysis runs the full default set, all 13 platforms listed above except mastodon (opt-in only). Billing is per platform queued, so a defaulted submission bills for the entire default set; send an explicit list to run (and pay for) fewer platforms. PARTIAL SUCCESS IS NORMAL: each platform runs independently. Check per-platform status while polling. Facebook and Instagram are best-effort (frequently blocked upstream); a run where only those fail still counts as completed. TIKTOK REPOSTS: for account research, pass "tiktok": {"username": "example", "include_reposts": true, "max_reposts": 100}. Omit max_reposts or use null to collect until the public feed ends or collection is interrupted. Reposts are optional and off by default. TikTok results expose uploads_fetched, reposts_fetched, reposts_requested and reposts_complete. A false reposts_complete means coverage is incomplete even if the platform status is completed; zero collected reposts does not establish that the account has none. content_items labels each entry's content_type as upload or repost. Reposts keep the original author's details and video URL. Do not attribute shared statements to the subject or treat a repost alone as proof of endorsement. HANDLES: when no handle is supplied for a platform, the run auto-discovers one and only proceeds on a confident, name-verified match; otherwise that platform fails with a clear "no confident handle found" error_message rather than guessing. Results include handle_used per platform (plus discovery confidence for auto-discovered handles). Facebook accepts multiple pages - candidates often run an official page plus a campaign page - via the per-platform config: {"facebook": {"username": "RepJaneExample", "usernames": ["JaneExampleForSenate"], "max_posts": 100}}. All listed pages are pulled and merged into one deduped Facebook section. Numeric profile IDs (pages with no vanity slug, e.g. "61575943587060") are valid Facebook handles. A Facebook pull that returns 0 posts retries once and, if still empty, fails with an explicit reason instead of returning a clean empty section. Long Facebook date ranges (start_date to end_date spanning more than ~6 months, or max_posts >= 500) are fetched in bounded date windows and stitched together, so deep-history pulls complete reliably. GET {base}/social-analysis/{job_id} // normalized per-platform status GET {base}/social-analysis/{job_id}/results // per-platform summaries when done Once a run is terminal with content (overall_status "completed" or "partial"), the /results response also carries a Word deliverable: document_url is a presigned link to a .docx that compiles the whole run (coverage table, cross-platform summary and themes, one section per platform with key topics + quoted posts + live links, plus explicit "ran clean" and "could not be analyzed" callouts). The document is built once on the first terminal read and cached; each /results call mints a fresh link, so it expires after document_url_expires_in_seconds (3600). While a run is still in progress both fields are null. No extra charge: the document is included in the run's credits. ## Product 3: Data Access (bulk database) Read-only, unlimited bulk access to Civly's full research database, delivered as a Parquet export in a per-partner storage bucket. You query it directly with DuckDB/SQL - no rows-per-call limits, no metering, no quota. Civly's infra provisions your export bucket ahead of time; these endpoints are read-only and require the data_access product on your key. Three endpoints (all GET, all need your key, none take a body): GET {base}/data/dictionary -> the data dictionary as JSON: one entry per dataset with a plain description, where it comes from, and the exact exported columns. GET {base}/data/sources -> the source catalog (the inventory of data sources behind the export) as JSON. GET {base}/data/export -> {"export_location": "...", "format": "parquet", "access": "read-only"} export_location is where your Parquet export lives (an s3://bucket/prefix path or a long-lived URL). Returns 409 if Civly has not provisioned your export bucket yet - contact Civly. Point DuckDB at the export_location and query the Parquet files directly. Use the data dictionary to learn the columns and the sources catalog for provenance. ## Product 4: Court opinion search Full-text search over Civly's own copy of ~10.7M U.S. court opinions (federal and state), served from Civly's cold data layer - no live third-party call. Synchronous and read-only: each search returns ranked results immediately. Requires the opinion_search product on your key. This product does NOT use the attestation or Idempotency-Key fields (it searches published court records, not a named private individual, and searches are safe to retry). Search: POST {base}/opinions/search { "query": "qualified immunity excessive force", "max_results": 10, // 1-50, default 15 "court_ids": ["ca9", "scotus"], // optional CourtListener court ids "judge": "Reinhardt", // optional "precedential_status": "Published", // optional "date_from": "2010-01-01", // optional, YYYY-MM-DD "date_to": "2020-12-31", // optional, YYYY-MM-DD "min_citations": 5 // optional } -> 200 { "query": "qualified immunity excessive force", "total_results": 10, "full_text_available": true, "results": [ { "opinion_id": 812345, "case_name": "Doe v. City of Example", "court_id": "ca9", "court_name": "Court of Appeals for the Ninth Circuit", "court_jurisdiction": "F", "judges": "Reinhardt, ...", "date_filed": "2014-06-02", "precedential_status": "Published", "citation_count": 42, "docket_number": "12-56789", "source_url": "https://www.courtlistener.com/opinion/812345/doe-v-city-of-example/", "snippet": "...the doctrine of qualified immunity does not shield..." } ] } Results are ranked best-first. full_text_available is true when they were ranked over the full opinion body. When the body index is not present in an environment, search falls back to matching case name and judges only and returns full_text_available: false - it never pretends to have searched the text. Fetch one full opinion: GET {base}/opinions/{opinion_id} -> 200 { ...same fields as a search hit..., "opinion_text": "Full opinion body text...", "truncated": false } Pass an opinion_id from a search result. Very long opinions are truncated to a fixed size cap; when that happens "truncated" is true and opinion_text holds the leading portion. Returns 404 if the id is not in the cold layer. ## Product 5: Epstein corpus screening Screen a person - and, optionally, their associates - against Civly's copy of the ~383,000-document DOJ / House Oversight Epstein release plus a curated associate watchlist. One synchronous call returns, per name, either a cited match or an explicit no-match. POST {base}/epstein-screening/screen { "subject_name": "Jane Q. Public", "additional_names": ["Acme Holdings LLC", "John Public"], // optional associates/family "use_corpus": true, // false = curated watchlist only "attestation": "subject-is-candidate-or-public-figure" } // required (see attestation section above) -> 200 { "results": [ { "name": "Jane Q. Public", "matched": false, "action": "pass", // "flag" = assert-worthy | "review" = weak, verify | "pass" = clean "confidence": 0.0, "tier": "none", // "watchlist" | "corpus_keyword" | "none" "doc_types": [], "citations": [], // each: { "label", "doc_type", "doc_id" (EFTA id where available) } "connections": [], "note": "No matches across the 383,000-document Epstein corpus." } ], "corpus_document_count": 383000, "disclaimer": "..." } Requires the epstein_screening product. Keep additional_names modest (<= 40 names). CRITICAL - read before using any result: a match is a POTENTIAL same-name reference, NOT proof the person is the individual named in the records, and NOT evidence of any wrongdoing. Presence in these documents is not an accusation. You MUST identity-verify a hit before asserting anything, and you MUST surface the returned "disclaimer" alongside any match you display. Reporting a hit as established fact may be defamatory. Triage with "action": "flag" is a watchlist or high-signal-document match; "review" is a weak email-only mention to check; "pass" means the name was screened with nothing found. ## Product 6: Adverse screening (free federal / international bad-actor lists) Screen a person - and, optionally, their associates - against the adverse screening lists Civly warehouses: HHS OIG healthcare exclusions (LEIE, with DOB + NPI where published), Federal Reserve enforcement actions, FBI Most Wanted, FDA debarments, the UN Security Council consolidated sanctions list, and FEC enforcement respondents (MURs / ADRs / administrative fines) - plus name-mention search across the ~268,000-document DOJ press-release archive. One synchronous call returns, per name, cited hits or an explicit no-match, with per-list freshness dates. POST {base}/adverse-screening/screen { "subject_name": "Jane Q. Public", "additional_names": ["Acme Holdings LLC", "John Public"], // optional associates/employers "attestation": "subject-is-candidate-or-public-figure" } // required (see attestation section above) -> 200 { "results": [ { "name": "Jane Q. Public", "matched": false, "hits": [], // each: { source, source_label, record_type, category, // full_name, entity_type, confidence, org_name, case_id, // detail_url (citation), summary, dob, dob_text, npi, // state, action_date, end_date, is_alias } "doj_mentions": [], // each: { title, url, pr_date, components } "doj_mention_count": 0, "note": "No matches across the adverse screening lists or the DOJ press-release archive." } ], "sources_checked": [ // what ran, so silence is unambiguous { "source": "hhs_oig_leie", "label": "HHS OIG healthcare exclusions (LEIE)", "record_count": 80000, "as_of": "2026-07-08T05:15:00+00:00", // last refresh attempt "last_success_at": "2026-07-08T05:15:00+00:00", // last refresh that succeeded "is_stale": false } // true = no successful refresh in 14 days; say so ], "disclaimer": "..." } Requires the adverse_screening product. Keep additional_names modest (<= 40 names). These lists are ADVISORY research signals: none of them is a legal bar on donating, and sanctions/debarment compliance decisions still belong to the sanctions screening in your own compliance program. CRITICAL - the same attribution discipline as the Epstein screen applies: a hit is a POTENTIAL same-name match, NOT proof of identity and NOT evidence of wrongdoing by the person you screened. Use the returned dob / state / npi / employer fields to verify identity before relying on a hit, always surface the returned "disclaimer", and treat "confidence" as name-similarity only. Common names WILL produce same-name hits. ## Product 7: Municipal meeting moment search Full-text + speaker search over Civly's diarized, speaker-attributed archive of municipal meeting videos - roughly 7,300 meetings and 10,905 hours across 15+ states (city councils, boards, commissions). Each result is a "moment": a verbatim quote, who said it, which meeting and when, a timestamp, and a link that plays that exact moment in the source video. Synchronous and read-only: each search returns ranked moments immediately. Requires the meeting_search product on your key. This product does NOT use the attestation or Idempotency-Key fields (these are public records of public meetings, and searches are safe to retry). Search: POST {base}/meetings/search { "query": "\"rezoning\" affordable housing", // keyword/phrase, websearch syntax; "quotes" for exact phrase "speaker": "Thorpe", // optional speaker name, partial match "state": "CA", // optional two-letter state "municipality": "Antioch", // optional, partial match "body_name": "City Council", // optional governing body, partial match "date_from": "2023-01-01", // optional, YYYY-MM-DD "date_to": "2024-12-31", // optional, YYYY-MM-DD "max_results": 10, // 1-50, default 15 "offset": 0 // paging offset, default 0 } -> 200 { "query": "\"rezoning\" affordable housing", "speaker": "Thorpe", "total_results": 12, "results": [ { "quote": "I move that we approve the rezoning for the affordable housing project on L Street.", "speaker_name": "Lamar Thorpe", "municipality": "Antioch", "state": "CA", "body_name": "City Council", "event_date": "2023-12-19", "timestamp": "1:35:07", "start_seconds": 5707, "source_url": "https://.../antioch-council-2023-12-19.mp4", "clip_url": "https://.../antioch-council-2023-12-19.mp4#t=5707", "citation": "[Antioch City Council, 12/19/23 @ 1:35:07]" } ] } Provide a query, a speaker, or both (at least one is required, else 422). Results are ranked best-first by relevance; page past max_results with offset and total_results. Open clip_url to play the meeting video at the exact moment (a media-fragment or timestamped link depending on the host). ATTRIBUTION: every returned moment has a resolved real speaker_name. A keyword hit spoken by an unresolved diarization label is never surfaced - so a search always returns attributed moments, never anonymous ones. Quotes are produced by automated transcription: treat clip_url / source_url as the authority and verify a quote against the video before asserting it. speaker names are auto-resolved and should likewise be spot-checked against the video for high-stakes claims. ## Product 8: NIL deal pricing (college athletics) An independent fair-market-value estimate for a proposed name-image-likeness deal, built bottom-up: every piece of work in the deal is priced against a published market rate for that kind of work, adjusted for the athlete, and added up. Nothing is a share of an athlete's annual valuation. Synchronous, read-only, and stored: each quote gets a quote_ref you can replay later. Requires the nil_pricing product on your key. Does NOT use the attestation or Idempotency-Key fields. Forward - price a deal: POST {base}/nil-pricing/price { "athlete": { "athlete_id": 8812, // Civly athlete id "season": 2025, // optional, defaults to most recent roster season "followers": {"instagram": 28000} // optional; only for athletes Civly does not carry }, "payor_name": "A regional bank", "payor_type": "business", // collective | business | associated_entity | unknown "deliverables": [ {"deliverable_type": "social_post", "quantity": 4, "platform": "instagram", "format": "static"}, {"deliverable_type": "appearance", "quantity": 2, "duration_minutes": 120}, {"deliverable_type": "likeness_usage", "geography": "regional", "term_months": 12} ] } -> 200 { "quote_ref": "nilq_...", "total_low": 8120.0, "total_high": 17300.0, "total_high_open": false, // true when a source published a floor and no ceiling "unpriced_line_count": 0, "rates_as_of": "2026-08-02", "lines": [ { "deliverable_type": "social_post", "quantity": 4, "priced": true, "rate_low": 280.0, "rate_high": 500.0, "rate_basis": "per_unit", "benchmark_id": 3, "benchmark_rate_low": 500.0, "benchmark_rate_high": 5000.0, "benchmark_tier": "10,000-100,000 followers", "extended_low": 1120.0, "extended_high": 2000.0, "placement_reason": "28,000 followers. The per-follower anchor puts a post at 280 ...", "citations": [ {"name": "Influencer Marketing Hub, ...", "url": "https://...", "as_of": "2026-08-02", "role": "anchor", "figure": "100 USD per 10,000 followers"} ] } ], "athlete": { "followers": {...}, "production_percentile": 0.92, "profile_tier": "regional", "profile_tier_reason": "...", "unresolved": [] }, "disclaimer": "An independent fair-market-value estimate ..." } benchmark_check sets the priced total against published annual earnings for this athlete's archetype, drawn from 150,000+ anonymised NIL transactions (Opendorse, filed as an exhibit in House v. NCAA). Every other figure in a quote comes from a rate survey; this is the only independent check that a bottom-up total is the right order of magnitude. It compares against COMMERCIAL earnings only. The same source publishes revenue share and collective money alongside, and those are roster pay, which the clearinghouse's own benchmarks discard. excluded_roster_pay_usd names what was deliberately held out. Null when no published archetype covers the athlete's sport and position, which is more common than not. clearinghouse places the priced total against published College Sports Commission deal flow: "clearinghouse": { "period_key": "may_june_2026", "period_label": "May and June 2026", "cleared_count": 7639, "cleared_total_usd": 112890000, "denied_count": 659, "denied_total_usd": 33680000, "approved_mean_usd": 14792, "denied_mean_usd": 51593, "review_threshold_usd": 600, "band": "at_or_below_approved_mean", "band_reason": "At 8,120 to 17,300 the whole of this range sits at or below ...", "caveat": "The approved and denied averages are not thresholds ...", "source_name": "...", "source_url": "https://...", "as_of": "2026-08-02" } band is one of below_review_threshold, at_or_below_approved_mean, straddles_approved_mean, between_means, straddles_denied_mean, above_denied_mean. Null when no line in the deal could be priced, because placing a total of zero would describe a Civly coverage gap as a price. READ THE CAVEAT, AND DO NOT STRIP IT. The two averages are NOT thresholds. Deals of every size both clear and fail and the populations overlap across most of their range, so a total above the denied average is not a prediction of denial and one below the approved average is not a promise of approval. This field places one number against two published numbers and claims nothing about any outcome. Presenting band without caveat turns a description into an approval prediction, which this product does not make. period_key is carried so a quote replayed months later resolves the same figures. The clearinghouse republishes periodically and the averages move; a stored quote keeps the ones it cited. There is no equivalent field on /nil-pricing/annual, because the clearinghouse reviews a single deal and a year is a sum across many, so the comparison would not be like for like. deliverable_type is one of social_post, appearance, likeness_usage, tv_spot, radio_spot, podcast, signing, camp, licensing, speaking, exclusivity. Seven have sourced rates today: social_post, appearance, likeness_usage, exclusivity, podcast, camp and speaking. The rest return "priced": false with an "unpriced_reason" rather than an estimate - tv_spot and radio_spot because spot rates are quoted by Nielsen DMA and every market figure Civly holds is CBSA; signing and licensing because no published rate was found. An unpriced line is counted in unpriced_line_count and contributes nothing to the total. Never present a total as complete without checking that field. Measured engagement widens the social_post line. Where Civly holds an athlete's engagement rate and it clears the published band for their follower tier (1.5-3.5% for static Instagram posts at 10,000-100,000 followers), the line's FLOOR becomes the higher of the per-follower anchor and the surveyed tier floor, and its CEILING becomes the top of the band they cleared. Neither figure prices the engagement itself: what an engagement rate is worth in dollars is not published, and where the athlete lands inside that range is exactly the unpublished part. Report it as a range and render the placement_reason, which shows the multiple over the band. Clearing a band never prices an athlete below their own follower count. EVERY SOCIAL RATE CIVLY HOLDS IS SURVEYED FROM THE CREATOR MARKET, NOT FROM ATHLETE DEALS. No disclosed-athlete-deal source is loaded. The sources are influencer-marketing publishers, none of which states a methodology, a sample size or a collection window, and one of which compiles its figures partly from another. Where a published athlete transaction figure exists to check against, this engine's bottom-up total reads well below it. Every social_post placement_reason carries this in full: render it, and present a social line as a conservative floor rather than as a market rate. Do not describe these rates as athlete rates. Three of the priced types need care in how you describe them: - exclusivity is NOT a percentage uplift. It is priced as 13-week holding-fee cycles under the SAG-AFTRA commercials contract, which is the payment that stops a performer working for a competing product. Pass term_months. It covers COMPETING products only; blocking an athlete from non-competing categories is separately negotiated and its amount is not published, so the line is a floor for that case. Terms past the contract's 24-month maximum period of use are capped at eight cycles and the excess is explicitly not priced. - podcast is priced on the SHOW's audience, not the athlete's. Pass `audience` (downloads per episode) on that deliverable. Without it the line is unpriced rather than falling back to the athlete's follower count, which would be a different audience entirely. - speaking is priced off the appearance range as an acknowledged PROXY. No rate specific to college athlete speaking is published, and the speaker-bureau brackets that exist quote professional and retired athletes. The placement_reason says so; repeat that, do not present it as a speaking rate. Reverse - show the work a target budget would require: POST {base}/nil-pricing/reverse { "athlete": {"athlete_id": 8812}, "target_usd": 200000, "geography": "regional", "deliverable_mix": ["social_post", "appearance", "likeness_usage"] } // optional -> 200 { "target_usd": 200000.0, "package_total_usd": 202960.0, "feasible": false, "credible_ceiling_low": 13960.0, "credible_ceiling_high": 28800.0, "shortfall_multiple": 6.94, "lines": [ {"deliverable_type": "social_post", "quantity": 104, "max_per_season": 7, "exceeds_cap": true, ...} ], "volume_constraints": [ {"deliverable_type": "social_post", "max_per_season": 7, "required_for_target": 104, "exceeds": true, "source_kind": "derived", "rationale": "..."} ], "warnings": ["No package at credible volume reaches 200,000. The most this athlete's whole season of inventory supports is 13,960-28,800 ...", "Before any question of volume: at 200,000 the whole of this range sits above the 51,593 average deal the clearinghouse denied ..."], "clearinghouse": { "band": "above_denied_mean", ... } } In reverse mode clearinghouse places the TARGET, not the package. The target is the number the caller arrived with, and reverse mode exists to examine it rather than to dress it. When the target reaches the size denials concentrate at, that also appears in warnings, prefixed "Before any question of volume:". Same caveat as forward mode: the averages are not thresholds and nothing here predicts an outcome. Every priced deal also returns "athlete_year": the athlete's whole season, so the deal can be read against what they are worth rather than in isolation. "deal_share_of_commercial_pct" is this deal as a share of their COMMERCIAL season only - school pay is a separate pool and setting an endorsement against it would make every deal look negligible. "school_low"/"_high" and "total_low"/"_high" ride along for context. Null when the season could not be priced. READ feasible AND warnings BEFORE package_total_usd. When feasible is false, the package reaches the target only at volumes the athlete cannot credibly deliver, and the total is not a defensible price for their work - credible_ceiling_low/high is. Presenting package_total_usd from an infeasible response, or presenting it without the warnings, misrepresents the answer. Volume maxima carry a source_kind, and the three values do not mean the same thing: - "derived": arithmetic over two published figures, with the working shown in the rationale so you can redo it. Social-post and appearance counts are derived per sport from transaction data covering 125,000+ student-athletes. - "published": somebody's measurement, named in source_name. - "civly_assumption": Civly's own reasoning. The rationale must travel with any figure that rests on one. Do not describe a civly_assumption count as sourced. Annual - value a whole year of an athlete's commercial inventory: POST {base}/nil-pricing/annual { "athlete": {"athlete_id": 8812}, "season": 2025, "geography": "national", "committed": {"social_post": 4, "appearance": 1} } // optional, what is already signed -> 200 { "annual_low": 13960.0, "annual_high": 28800.0, "assumption_backed_usd": 15000.0, "sport_activity_count": 22.0, "activity_count_check": "This year totals 11 pieces of commercial work. Transaction data puts a top-25 earner in football at 22.0 commercial NIL activities a year, so this package is 50% of that athlete's published annual activity ...", "excluded_work": {"tv_spot": "no sourced broadcast rate yet ...", ...}, "lines": [ {"deliverable_type": "social_post", "max_per_season": 7, "annual_low": 1960.0, "annual_high": 3500.0, "cap_source_kind": "derived", "cap_sport": "football", "volume_note": "Derived, not assumed: a top-25 earner in this sport is expected to do 22.0 commercial NIL activities a year, and 31.4% of commercial activities are social posts ...", "line": { ...the priced unit rate, with its own citations... }} ] } This is built BOTTOM UP: each line is a published rate for that work times a per-season count for the athlete's sport. It is NOT a share of any published annual NIL valuation, and you must not describe it as one. On3-style valuations have measured roster value since 1 July 2026 - what schools and collectives pay - which is the pool this figure deliberately excludes. Three fields decide how much weight the total carries: - assumption_backed_usd: how many of the dollars rest on a count that is Civly's rather than sourced. Report it alongside the total, never instead of it. - excluded_work: kinds of work left out entirely and why. The figure is a floor on commercial value, not a ceiling, because these are missing from it. - activity_count_check: the package's total piece count against published annual activity counts for the sport. A package well above 100% of it is overstating the athlete. Counts exist for football, men's and women's basketball, women's volleyball, softball and baseball. An athlete in any other sport gets max_per_season: null on that line, and there is then NO annual total at all - see below. "annual_low"/"annual_high" ARE NULL WHENEVER THE YEAR WOULD BE SHORT A LINE. A year that drops a line it should carry reads as complete and is not, and the error only ever runs one way: a missing line makes the athlete look cheaper, so any ratio with this figure in the denominator goes UP. So the endpoint withholds the number instead of returning a short one. Two things trigger it, both Civly's gap rather than the athlete's: - no sourced rate covers the athlete (Instagram brackets run 10,000 to 10,000,000 followers; outside that span a real audience prices at nothing), or - a rate priced but no sourced per-season count exists for that sport. When it fires, "annual_unavailable_reason" says which lines are missing and why, "incomplete_lines" lists their deliverable types, and "total_low"/"total_high" are null too. Do NOT add the lines up yourself to recover a total - that reproduces exactly the bug this refuses to commit. Report the reason and the lines that did price, each with its own figure. A line unpriced because an input about the ATHLETE was missing (no follower count on record) does not trigger it: the year still totals, and that line carries coverage_gap: false. A priced social line can also carry rate_floor_bound: true, meaning the published tier floor set the rate because the athlete's own audience prices below it. The figure is real, but audience did not move it and a larger audience inside the same span would not either. Say so if you are asked how the price responds to following. Pass "committed" and each line also returns committed / remaining / remaining_low / remaining_high, plus remaining_low/_high on the response: what is still open to sell. A deliverable you leave out of the committed map counts as nothing signed, because a caller listing what is signed is giving a complete list. Omit "committed" entirely and every remaining field is null - that means we were not told, not that nothing is signed, and you must not render it as zero. A line with more signed than the season holds returns oversold: true and appears in oversold_lines; report that instead of the total. THE TOTAL. "total_low"/"total_high" are commercial plus roster pay: everything the athlete earns in a season. "roster_pay" is that second pool on its own (revenue share plus collective money), and "annual_low"/"annual_high" stay the commercial half. Report the total WITH both halves, never the total alone: the halves are not equally precise and only one of them moves with the athlete. "total_basis" says which. For a top football quarterback roster pay is around 91% of the total, so an athlete who doubles their following barely moves the total. roster_pay is ESTIMATED, not looked up. A published annual pay range for the athlete's position (from a survey of 20+ college general managers and agents, 2024-25 transfer cycle) with the athlete placed inside it by production percentile against peers at that position. THOSE RANGES ARE A CYCLE STALE AND READ BELOW MARKET. The survey covers 2024-25 and no per-position replacement has been published by anyone since. Measured against 2026 transfer portal data, the published HIGH sits below the observed MEDIAN on six of eleven positions. A quote for any season after 2025 carries this in its placement_reason, naming the cycle the figures describe and how many seasons late it is. Render it, and treat a roster-pay figure for a current season as a FLOOR rather than an estimate. The figure is never adjusted upward to compensate: no published growth rate covers it, and a guessed one would not be a source. Read "annual_low"/"annual_high" (the placed band) rather than "annual_usd" (its midpoint), and render "placement_reason", which carries the published range, the athlete's rank, and the arithmetic between them. "caveat" carries the source's own limitation and must travel with any figure quoted from it. Two athletes at the same position are separated by production, so a starter and his backup do NOT return the same figure. Where production cannot be resolved, the whole published range is returned and "precision" reads "athlete not placed inside it" - do not present that as a narrowed estimate. AUDIENCE IS NOT AN INPUT TO ROSTER PAY and you must not describe it as one. Schools pay to win games; our own analysis measured follower count at a slightly negative correlation with value at the top of this market. Follower counts drive the commercial half only. Null total means no published pay range covers the athlete's position. "total_unavailable_reason" says so. Do NOT build a total by subtracting our commercial estimate from a published annual total: that subtracts a per-athlete figure from a top-25 archetype average, and the remainder absorbs every way the athlete differs from a top-25 earner before being labelled school money. "archetype_total" is the published all-in annual figure for a top-25 earner in the sport, available for six sports. IT PREDATES REVENUE SHARING ("predates_revenue_sharing": true, period 1 July 2021 to 7 June 2024). Revenue sharing began in 2025-26 and is now the largest line in a top athlete's package, so this reference reads several times BELOW a current total and is not a check on one. Never present a gap between them as an error. "campaign_geography" echoes the reach used for the likeness line. It swings the total by roughly seven times (regional vs national) and describes the deal rather than the athlete, so state it whenever you state the total. Audit the rates: GET {base}/nil-pricing/rate-card -> 200 { "count": 8, "rates": [ {"id": 3, "deliverable_type": "social_post", "tier_low": 10000, "tier_high": 100000, "rate_low": 500.0, "rate_high": 5000.0, "unit_note": "One in-feed post to the athlete's own account. Organic only ...", "source_name": "...", "source_url": "https://...", "as_of": "2026-08-02"} ] } Replay a quote: GET {base}/nil-pricing/quotes/{quote_ref} -> 200 the quote exactly as it was issued, with the rates and source dates it cited Replay does not recompute. Rates are resurveyed annually, so a fresh computation would return a different number and prove nothing about the quote being asked about. WHAT THIS IS NOT: it is not a prediction of whether a deal will clear NIL Go, and it carries no accuracy claim against the College Sports Commission's compensation-range analysis, which is a private statistical process Civly cannot see or replicate. It is an independent estimate whose value is that every figure traces to a named, dated source the reader can open. Do not describe it as an approval predictor or quote an accuracy rate. ## Product 9: Clip feed (your Clip Library, for dashboards) Pull the clips in your own Clip Library into your own dashboards or warehouse. One flat row per clip (no nested objects, so each clip maps straight to one CSV row), oldest first, paged by clip_id. Built for a scheduled job that asks "what is new since the last clip I saw". Requires the clip_feed product on your key. Read-only and safe to retry; it does NOT use the attestation or Idempotency-Key fields. Only your company's clips are returned, never another account's. List clips: GET {base}/clips?after_id=0&limit=100 GET {base}/clips?after_id=18233&limit=500&collection=acme-for-congress -> 200 { "clips": [ { "clip_id": 18234, "collection": "Acme for Congress", // usually one collection per client "collection_slug": "acme-for-congress", "title": "Smith on the gas tax", "quote": "We are not going to raise the gas tax. Period.", "speaker": "Jane Smith", "category": "taxes", "severity": 3, "reason": "Contradicts her 2024 vote on HB 112", // why an AI-found clip was flagged "show": "Evening News", "channel": "WXYZ", "platform": "tv", "air_date": "2026-10-01", "start_seconds": 1312.4, "end_seconds": 1341.0, "duration_seconds": 28.6, "tags": "gas-tax, debate", // comma-separated "source_url": "https://www.youtube.com/watch?v=...", "play_url": "https://app.civly.ai/api/v1/partner/clips/18234/play?sig=...", "thumbnail_url": "https://app.civly.ai/api/v1/partner/clips/18234/thumbnail?sig=...", "created_at": "2026-10-01T22:14:09Z" } ], "has_more": true, "next_after_id": 18234 } Parameters: after_id (default 0 = from the start; returns clips with clip_id above it), limit (1-500, default 100), collection (optional collection_slug; unknown slug -> 404). Any field except clip_id, created_at and the paging fields can be null. Paging and incremental runs: keep calling with after_id = next_after_id while has_more is true. Store the final next_after_id; next run, start from it to receive only clips added since. When nothing is new, clips is empty and next_after_id echoes your after_id. clip_id is stable: use it as the row key when loading into your system. A clip enters the feed five minutes after it is created, so a run never sees a clip that is still being saved. Rows are sent once: a clip edited later is not sent again, and a clip that was hidden when you pulled past it and is shown again later is not sent at all. If you need those, re-pull from an older after_id now and then and dedupe on clip_id. Example scheduled pull (Python, writes one CSV per run): import csv, requests BASE = "https://app.civly.ai/api/v1/partner" HEADERS = {"Authorization": "Bearer cvly_pk_YOURKEY"} after_id, rows = load_last_after_id(), [] # your own storage; 0 on the first run for _ in range(50): # at most 25,000 clips per run, so a run that # must finish in minutes always does; a large # backlog completes over the next few runs page = requests.get(f"{BASE}/clips", headers=HEADERS, params={"after_id": after_id, "limit": 500}, timeout=30).json() rows += page["clips"] after_id = page["next_after_id"] if not page["has_more"]: break if rows: with open("civly_clips.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0])) writer.writeheader() writer.writerows(rows) save_last_after_id(after_id) Play and thumbnail links: play_url and thumbnail_url are lasting links that need no API key and no login, so you can store them and show them to your clients. Opening one redirects (302) to a short-lived video or image URL. play_url opens the cut clip when one exists; otherwise it opens the full source video at the clip's moment (a #t=start,end media fragment) or, for clips with no stored video, the original public source_url. A TV clip whose video is still being converted answers 503 with Retry-After: 60 - open it again a minute later. play_url is null only when the clip has no video at all, and thumbnail_url is null when there is no image. Each link works only for its own clip (a signature in the sig parameter); a changed or missing sig returns 404. Removing or hiding a clip in the Clip Library makes its links return 404, so a stored link can stop working after the clip is taken down. Embed the links as-is; do not cache the redirect target, which expires within an hour. Because the links carry no key, they have their own rate limit: 600 opens per minute per viewer IP address (429 with Retry-After beyond that). Redirects are cacheable for five minutes, so reloading a page of thumbnails does not count again. Links also stop working if your API key is revoked or no longer includes the clip_feed product. ## Completion webhooks (optional - skip polling for reports) Give Civly an https webhook URL and you receive a signed POST when a report finishes: {"event": "report.completed", "delivery_id": "...", "product": "big_book", "job_id": "1234", "status": "completed", "timestamp": "..."} Events: report.completed, report.failed. The body NEVER contains report content - fetch results through the authenticated result endpoint. report.completed means generation finished: the raw data is ready, but the polished document may still be in quality review - check document_status on the result endpoint. Verify the signature header "X-Civly-Signature: t=,v1=": import hashlib, hmac, time def verify(secret: str, header: str, body: bytes) -> bool: parts = dict(item.split("=", 1) for item in header.split(",")) ts, sig = parts["t"], parts["v1"] if abs(time.time() - int(ts)) > 300: return False expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig) Respond 2xx within 10 seconds (ack fast, process async). Failed deliveries retry at 1m/5m/15m/30m/60m, then stop - poll as the fallback. Use delivery_id to deduplicate. ## Error reference and debugging | Status | Meaning | What to do | |--------|---------|------------| | 401 | Key missing/invalid/revoked | Check the Authorization header format ("Bearer cvly_pk_..."); confirm the key wasn't revoked; no trailing whitespace/newline in the key. | | 402 | Insufficient credit balance | The account is out of credits. Contact Civly - this is not retryable from code. | | 403 | Product not enabled for this key | Your key's access level excludes this product. Contact Civly to add it. | | 404 | Job not found | Wrong job_id, or the job belongs to a different company (keys only see their own jobs). Verify you stored the job_id from the submit response. | | 409 | Result not ready | The report is still generating. Keep polling status; only call /result after status == completed. | | 422 | Validation error | Most common: attestation missing or not the exact string "subject-is-candidate-or-public-figure". Also: subject_name under 2 chars, unknown platform names, malformed dates. The response body lists the exact field. | | 429 | Quota / concurrency / capacity / rate limit | Four causes. The product-level ones carry an "error" field: "Monthly quota reached" (wait for next calendar month or ask Civly to raise it), "Concurrent job limit reached" (wait for an in-flight report to finish), "Partner capacity reached" (shared capacity is momentarily full - retry with backoff in a few minutes). The platform rate limiter instead returns a "detail" field ("Too many requests...") with a Retry-After header when a key exceeds 120 requests/minute - pace requests and retry after the indicated delay. Successful responses include X-RateLimit-Limit and X-RateLimit-Remaining headers. | | 503 (clip play link) | TV clip still being converted | Only from a clip play_url. Open it again after the Retry-After delay (60 seconds). | | 5xx | Server error | Retry with exponential backoff. If a submit may have gone through, retry WITH THE SAME Idempotency-Key - you will get the original job back instead of a duplicate. | Debugging checklist when something seems stuck: 1. status == "queued" for more than ~5 minutes: the queue is busy or a dispatch is being retried internally; keep polling, it self-heals. 2. status == "failed": resubmit with a NEW Idempotency-Key (reusing the old key replays the failed job, it does not retry it). If it fails repeatedly for the same subject, contact Civly with the job_id. 3. Webhook never arrived: poll GET /reports/{job_id} - polling always works; then ask Civly to check the delivery log for your key. 4. Download link expired (403/404 from the storage URL): call GET /reports/{job_id}/result again for a fresh presigned link. 5. Social analysis "completed" but a platform is missing: check the per-platform statuses in /results - that platform likely failed or was skipped; the others are still valid. 6. Report "completed" but document_url is null: not an error - the document is in human quality review (document_status "in_review"). Use raw_data_url meanwhile and re-check /result later. ## Rules of engagement - Do not poll faster than every 30 seconds per job. - Store job_ids; they are your receipts (and what invoices reference). - One Idempotency-Key per logical operation; never reuse across subjects. - Keep your webhook secret and API key in a secrets manager, separately. - Subjects must be candidates or public figures; every request is audited. Questions: matthew@civly.ai ## Data sources behind reports Reports draw on 170+ source families. This list is generated from the live source registry at request time, so it is always current. - Campaign finance and political money: Behested payments, Endorsements, FEC campaign contributions, Google political ad spending, Lobbying contributions, Lobbying records, Political email archives, State and local campaign finance, State lobbyist filings - Government, votes, and policy: Census demographics, Election history, Federal register documents, Federal research grants, Federal voting records, Government contracts and grants, Meeting minutes, Municipal meeting video transcripts, North Carolina voter file (weekly refresh), Regulatory comments, State contract awards, State legislation, Voter registration - Courts, enforcement, and accountability: CFPB consumer complaints, Court records (dockets and opinions), Criminal records, Data breaches, EPA enforcement actions, FARA foreign-agent registrations, FINRA BrokerCheck records, Judicial discipline, Judicial profiles, OSHA workplace violations, Offshore leaks (ICIJ) - Wealth, business, and finances: Business reviews, CMS Open Payments (physician industry payments), Charitable gifts, Congressional net-worth disclosures, Congressional stock trades, Federal financial disclosures (OGE 278e), Glassdoor reviews, LittleSis relationship mapping, NPI healthcare provider registry, Nonprofit filings (IRS 990s), PPP loans, Property records, SEC Form ADV investment-adviser records, SEC Form D offerings, SEC filings, State personal financial disclosures, UCC lien filings, USPTO patent assignments, USPTO patents, USPTO trademark assignments - Polling and forecasts: Polling aggregation, Prediction markets - Media and web presence: Campaign websites, Deleted social post recovery, News articles, Opponent campaign websites, Wayback Machine web archives, Wikipedia, Wikipedia source references - Social platforms: Bluesky, Facebook, Instagram, LinkedIn, Mastodon, Reddit, Rumble, Substack, TikTok, Truth Social, Twitch, X / Twitter, YouTube - Licenses, registrations, and assets: FAA aircraft registry, FCC radio licenses, FMCSA motor carrier registrations, GLEIF legal-entity identifiers, Public employee salaries, SAM.gov entity registrations, State professional licenses, USCG vessel registrations, USDA farm subsidies - Additional public records: AI-enriched meeting minutes, Academic publications, Adverse screening, Bls qcew county employment, Business litigation records, Ca unclaimed property, California charity disclosures, California warn, Campus safety disclosures, Census cps voting supplement, Census school district finance, Cfbd archive, Civil dockets, Clinicaltrials registry, Cms facility ownership, Cms hospital ownership, Cms nursing homes, Cms partd prescribers, Contractor rosters, County records, DOL wage-and-hour enforcement, Doj press releases, Dol form5500, Dol oflc applications, Eada athletics, Eia860 energy assets, Epa tri releases, FEC compliance filings, Fac federal awards, Fcc wedu donor disclosures, Fda adverse events clearances, Fdic banks, Federal bills and legislative effectiveness, Federal grant opportunities, Federal place designations, Federal press releases, Federal product recalls, Federal reserve nic, Federal single audits, Fema risk disaster flood, Gao congressional funding, Grantmaker application terms, Gsa lease inventory, Hud multifamily housing, Ipeds institution detail, Irs 527 political organizations, Irs 990 financials, Irs exempt organizations, Irs soi zip income, Ks campaign finance expenditures, Land use permits, Local contested project actions, Meta ads, New york ida projects, Nih exporter projects, Nlrb cases elections, Nonprofit acknowledgement documents, Nonprofit board networks, Nonprofit political crossover, Nppes bulk, Occ enforcement actions, Ohio professional licenses, Opm federal employees, Pa business entities, Penn state sponsor directory, Position and employment history, Private foundation grants (IRS 990-PF), Property transfers, Pubmed bibliography, Sam contract notices, Sba 7a 504 loans, Sec 13f holdings, Sec openfigi tickers, Senate privately funded travel, Snapchat, State hr commissions, State medicaid exclusions, Stock portfolio analysis, Tax liens, Threads, Unc athletics contract library, Union lm filings, Uspto pregrant publications, Uspto trademarks, Uspto ttab proceedings, Whitehouse visitor logs