Mesh API

Public REST API for querying political speech, transcript segments, speakers, topics, usage, API keys, and saved analyses.

Base URL: https://api.meshapi.app
Interactive reference: https://api.meshapi.app/docs

Use this as the source for public API documentation on the Mesh frontend.

Quick start#

curl "https://api.meshapi.app/v1/events?limit=10&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

Authentication#

All /v1/* endpoints require authentication except GET /health.

Pass your API key in the X-API-Key header:

X-API-Key: mesh_your_key_here

Create and manage API keys from your account dashboard on the Mesh web app.

Plans and quotas#

PoolWhat countsReset
Transcript pullsGET /v1/events/{event_id}/segments, deduped by event per UTC dayDaily and monthly
SearchesGET /v1/segments/search, GET /v1/search/eventsDaily
Other endpointsMetadata, usage, topics, speakers, saved analysesUnmetered
PlanDaily transcript pullsDaily searchesMonthly transcript cap
Free3/day10/day3/month
Trial (14 days)5/day50/day150/month
Pro15/day150/day450/month
Max30/day300/day900/month
EnterpriseContract-definedContract-definedContract-defined

Caps are account-wide. All of your API keys share the same daily and monthly budget. Caps reset at 00:00 UTC daily and on the first of each month.

Rate Limits#

In addition to quota caps, all API-key requests are subject to a per-key request-per-minute rate limit:

PlanRequests per minute
Free / Trial / Pro / Max60
Enterprise300

JWT (web) callers have a separate 120 req/min limit per user. Rate-limited requests return 429 with a Retry-After header.

Response format#

List endpoints use a paginated envelope:

{
  "data": [],
  "next_cursor": "dGVzdA==",
  "count": 50
}

Single-resource endpoints return the object directly. Errors return:

{ "detail": "Message describing the error" }

Common errors#

StatusMeaning
400Invalid parameter or request body
401Missing, invalid, or expired credentials
403Account disabled, resource outside key scope, or Free-plan event not unlocked
404Resource not found
409Conflict, such as max active keys reached
422Validation error
429Daily/monthly quota exceeded

Quota errors include useful reset headers when applicable:

  • Retry-After
  • X-Pull-Budget-Reset

Speaker identity#

Speaker identity has two layers:

  • Event speakers describe who an event is attributed to, including umbrella or group speakers such as Fox World Cup Announcers. Use primary_speaker_id on event endpoints.
  • Segment speakers describe who actually spoke a transcript segment after diarization and resolution. Use segment_speaker_id on segment endpoints and segment search.

The legacy speaker_id parameter remains supported for compatibility, but its meaning depends on the endpoint: on GET /v1/events it aliases primary_speaker_id; on segment endpoints it aliases segment_speaker_id.

Endpoints#

Health#

GET /health

Public health check.

{ "status": "ok" }

Events#

GET /v1/events

List events. Use has_transcript=true to browse events with transcript data.

Common query params:

ParamTypeDescription
qstringOptional full-text search on event title
event_typestringEvent type such as remarks, press_conference, rally, interview
primary_speaker_idUUIDFilter by event attribution / primary speaker
primary_speaker_idsstringComma-separated UUIDs (max 20). Returns events where ANY listed speaker is primary.
segment_speaker_idUUIDFilter to events where this resolved speaker appears in transcript segments
segment_speaker_idsstringComma-separated resolved segment speaker UUIDs
speaker_idUUIDLegacy alias for primary_speaker_id
speaker_idsstringLegacy alias for primary_speaker_ids
topic_slugstringFilter by topic
citystringPartial city match
state_codestringExact state code
start_datedateEvents on or after this date
end_datedateEvents on or before this date
updated_afterdatetimeIncremental sync watermark. Returns only events with updated_at > updated_after.
sortstringevent_date_desc (default), event_date_asc, speaker_asc, event_title_asc, relevance
includestringOptional expansions. Use include=segment_speakers to include resolved segment speaker summaries on event-list rows.
limitintPage size, default 50
cursorstringCursor from previous response

Event rows include updated_at (RFC 3339 timestamp). Use this field to checkpoint incremental syncs.

When updated_after is omitted, pagination uses sort-aware opaque keyset cursors. A cursor is valid only with the same filters and sort value; changing either returns 422. When updated_after is provided, the endpoint switches to stable keyset pagination ordered by (updated_at, event_id) ascending. Reuse the same updated_after value for every page in that sync run. sort is not supported with updated_after sync mode.

GET /v1/events/{event_id}

Get one event. The response includes primary_speaker, segment_speakers, and speaker_summary so clients can distinguish event attribution from who actually spoke.

GET /v1/events/{event_id}/segments

Get transcript segments for one event.

Common query params:

ParamTypeDescription
limitintPage size
cursorstringCursor from previous response
segment_speaker_idUUIDFilter to one resolved segment speaker
speaker_idUUIDLegacy alias for segment_speaker_id
topic_slugstringFilter to one topic
unlockbooleanFree-plan users can set unlock=true to spend one daily unlock

Free-plan users may receive a preview response with unlock_required: true. Paid users and already-unlocked events return full access with unlock_required: false.

POST /v1/events/batch-segments

Pull transcripts for multiple events in a single request. Designed for bulk data collection workflows. Requires an API key (not available via JWT).

Request body:

{
  "event_ids": ["uuid1", "uuid2", "uuid3"],
  "limit_per_event": 500
}
FieldTypeDescription
event_idsUUID[]Event UUIDs to pull transcripts for. Max 25 (enterprise) or 10 (other plans).
limit_per_eventintMax segments per event, 1–500, default 500.

Response:

{
  "results": [
    {
      "event_id": "uuid1",
      "segments": [...],
      "next_cursor": null,
      "count": 142
    }
  ],
  "skipped": [
    { "event_id": "uuid3", "reason": "daily_cap_exceeded" }
  ],
  "pulls_used": 2,
  "pulls_remaining_daily": 13,
  "pulls_remaining_monthly": 448
}

Each unique event counts as one transcript pull, deduped by event per UTC day. Events that would exceed the daily or monthly cap are returned in skipped with the reason — the request never 429s partially. If an event needs more than limit_per_event segments, the entry includes a next_cursor that can be passed to GET /v1/events/{event_id}/segments?cursor=...&limit=500 to continue paginating that event.

Not available on the Free plan. Possible skip reasons: daily_cap_exceeded, monthly_cap_exceeded, not_found, speaker_not_in_scope, before_scope_date_range, after_scope_date_range.

GET /v1/events/{event_id}/topics

List topics attached to an event.

GET /v1/events/{event_id}/resolutions

List Kalshi market resolutions linked to an event when available.

GET /v1/segments/search

Search transcript segments across events.

Common query params:

ParamTypeDescription
qstringFull-text segment search
segment_speaker_idUUIDFilter by resolved segment speaker
primary_speaker_idUUIDConstrain search to events attributed to this primary speaker
speaker_expandstringSet to children to expand segment_speaker_id through speaker relationships
speaker_idUUIDLegacy alias for segment_speaker_id
topic_slugstringFilter by topic
event_typestringFilter by event type
start_datedateEvent start date lower bound
end_datedateEvent start date upper bound
sortstringrelevance (default), event_date_desc, event_date_asc, speaker_asc, event_title_asc
limitintPage size
cursorstringCursor from previous response

Free-plan search returns only preview segments plus events you have unlocked that day. Paid plans search the full corpus. Segment search cursors are opaque keyset snapshots. A cursor is valid only with the same q + filter + sort values; changing any of those returns 422.

GET /v1/search/events

Unified event-level search for frontend "All" mode. Combines title matches and transcript-segment matches, deduplicates by event_id, applies sort server-side, then paginates with one keyset cursor.

Common query params:

ParamTypeDescription
qstringOptional search text used for title and transcript matching
event_idUUIDOptional single-event scope
event_typestringFilter by event type
primary_speaker_idUUIDFilter by event primary speaker
segment_speaker_idUUIDFilter by resolved segment speaker
speaker_expandstringSet to children to expand speaker/group filters
speaker_idUUIDLegacy alias for segment_speaker_id
topic_slugstringFilter by topic slug
start_datedateEvent start date lower bound
end_datedateEvent start date upper bound
has_transcriptbooleantrue returns events with transcript data only; use this for transcript-backed All-mode results
sortstringrelevance (default), event_date_desc, event_date_asc, speaker_asc, event_title_asc
limitintPage size
cursorstringCursor from previous response

Speaker filtering uses union semantics: primary_speaker_id selects the event/title branch by event attribution, while segment_speaker_id selects the transcript-match branch by resolved speaker (with speaker_expand=children applied to that resolved-speaker branch). Supplying both does not require an event to satisfy both conditions.

Each result row includes event card fields plus match metadata:

  • primary_speaker_id and primary_speaker_name identify the event attribution speaker (not per-segment resolved speaker)
  • title_match
  • matching_segment_count (visibility-filtered for Free plan)
  • best_snippet, best_snippet_start_time_ms, best_snippet_idx, best_snippet_segment_id
  • topics: string[] of canonical event-topic slugs
  • speakers: string[] of canonical event-attribution speaker names
  • matched_segment_speakers: string[] of canonical resolved-speaker names on visible transcript matches
  • num_questions, starts_at, event_type

Event-search cursors are opaque keyset snapshots. A cursor is valid only with the same q + filters + sort; reusing it with different parameters returns 422. Free-plan visibility restrictions are applied before transcript counts/snippets are computed, so restricted transcript rows do not leak through match metadata.

Speakers#

GET /v1/speakers

List speakers.

GET /v1/speakers/{speaker_id}

Get one speaker.

GET /v1/speakers/{speaker_id}/events

List events where the speaker is primary or appears in resolved transcript segments.

GET /v1/speakers/{speaker_id}/children

List direct child speakers for an umbrella/group speaker.

GET /v1/speakers/{speaker_id}/parents

List umbrella/group speakers that include this speaker.

GET /v1/speakers/{speaker_id}/stats

Get speaker-level statistics when available.

Topics#

GET /v1/topics

List topics. Add hierarchy=true to return parent/child structure.

GET /v1/topics/{slug}

Get one topic.

GET /v1/topics/{slug}/segments

Browse transcript segments attached to one topic.

Usage#

GET /v1/usage

Return your current plan, daily transcript pulls, daily searches, monthly transcript usage, and trial metadata. Does not consume quota.

Important fields:

FieldDescription
planCurrent effective plan
daily_transcript_pulls_used / daily_transcript_pulls_capTranscript pull usage today
daily_searches_used / daily_searches_capSearch usage today
monthly_transcript_pulls_used / monthly_transcript_pulls_capMonthly transcript ceiling
trial_started_at / trial_ends_atTrial metadata, if applicable

Pull History#

GET /v1/pulls

List transcripts the authenticated user has fetched and whether Mesh has newer transcript data available.

Common query params:

ParamTypeDescription
limitintPage size
offsetintOffset pagination value
api_key_idUUIDOptional key-specific history filter

Use has_updates to show stale transcript pulls in a user dashboard.

API Keys#

Manage your API keys from the Mesh web app. Keys can be created, listed, and revoked from your account dashboard.

The raw key value is shown once at creation. Store it securely. It cannot be retrieved again.

Analyses#

POST /v1/analyses/run

Run supported analysis jobs over the transcript corpus.

POST /v1/analyses/word-streaks

Run word-streak analysis.

GET /v1/analyses

List saved analyses.

POST /v1/analyses

Save an analysis configuration.

GET /v1/analyses/{analysis_id}

Get one saved analysis.

PUT /v1/analyses/{analysis_id}

Update a saved analysis.

DELETE /v1/analyses/{analysis_id}

Delete a saved analysis.

Workflows#

Browse and Discover Events#

List all transcribed events

curl "https://api.meshapi.app/v1/events?has_transcript=true&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

Results are paginated. Pass the next_cursor value from the response as ?cursor=... to fetch the next page. Repeat until next_cursor is null.

Filter events by primary speaker

curl "https://api.meshapi.app/v1/events?primary_speaker_id=381194fe-4856-4945-9e49-9809be82e924&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

Get speaker UUIDs from GET /v1/speakers. speaker_id still works here as a legacy alias, but new clients should use primary_speaker_id.

Filter events by segment speaker

curl "https://api.meshapi.app/v1/events?segment_speaker_id=381194fe-4856-4945-9e49-9809be82e924&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

This returns events where that speaker appears in resolved transcript segments, even if the event's primary speaker is an umbrella/group speaker.

Filter events by topic

curl "https://api.meshapi.app/v1/events?topic_slug=nato&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

Browse available topic slugs with GET /v1/topics.

Filter events by date range

curl "https://api.meshapi.app/v1/events?start_date=2026-01-01&end_date=2026-03-31&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

Dates are ISO format. Both start_date and end_date are optional and can be used independently.

Combine multiple filters

curl "https://api.meshapi.app/v1/events?primary_speaker_id=381194fe-...&event_type=press_conference&start_date=2026-01-01&has_transcript=true" \
  -H "X-API-Key: mesh_your_key_here"

All filters use AND logic. This returns only press conferences by that speaker from 2026 onward that have transcripts.

Incrementally sync newly changed events

curl "https://api.meshapi.app/v1/events?updated_after=2026-01-01T00:00:00Z&limit=200" \
  -H "X-API-Key: mesh_your_key_here"

Store the highest updated_at you have fully processed as your next watermark. For paginated incremental sync:

  • Keep updated_after fixed across pages.
  • Pass the returned next_cursor until it is null.
  • Deduplicate by event_id client-side within a run.

If updated_after and cursor don’t match (for example, using a cursor from a different watermark), the API returns 422.

Pull Transcripts#

Pull a full transcript

curl "https://api.meshapi.app/v1/events/70d5bc85-cd9b-4670-94fb-6d5a7433b1d0/segments?limit=500" \
  -H "X-API-Key: mesh_your_key_here"

Use limit=500 (the maximum) to minimize round trips. If next_cursor is not null, pass it as ?cursor=...&limit=500 to get the next page. Each call counts as one transcript pull for quota purposes, deduped by event per UTC day.

Pull segments by speaker within a transcript

curl "https://api.meshapi.app/v1/events/70d5bc85-.../segments?segment_speaker_id=381194fe-...&limit=500" \
  -H "X-API-Key: mesh_your_key_here"

Returns only segments from that resolved speaker within the event. Useful for isolating one person's remarks in a multi-speaker transcript. speaker_id remains a legacy alias on segment endpoints.

Pull segments by topic within a transcript

curl "https://api.meshapi.app/v1/events/70d5bc85-.../segments?topic_slug=nato&limit=500" \
  -H "X-API-Key: mesh_your_key_here"

Returns only segments tagged with that topic within the event.

Search Across Events#

Full-text search

curl "https://api.meshapi.app/v1/segments/search?q=tariffs&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

Multi-word queries use AND semantics: q=trade+tariffs matches segments containing both words. Each successful search call counts against your daily search quota.

Search within one segment speaker's transcripts

curl "https://api.meshapi.app/v1/segments/search?q=immigration&segment_speaker_id=381194fe-...&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

Combines full-text search with a resolved segment speaker filter.

Search inside an umbrella speaker's events

curl "https://api.meshapi.app/v1/segments/search?q=goal&primary_speaker_id=381194fe-...&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

This is the right filter when the selected speaker is an event collection or broadcast group, such as Fox World Cup Announcers.

Search within a topic

curl "https://api.meshapi.app/v1/segments/search?q=sanctions&topic_slug=nato&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

Returns segments that match the text query and are tagged with the specified topic.

Search within event type and date range

curl "https://api.meshapi.app/v1/segments/search?q=economy&event_type=remarks&start_date=2026-01-01&end_date=2026-06-01&limit=50" \
  -H "X-API-Key: mesh_your_key_here"

Narrows search to a specific event type and time window. All search filters use AND logic.

Monitor Usage and Changes#

Check quota usage

curl "https://api.meshapi.app/v1/usage" \
  -H "X-API-Key: mesh_your_key_here"

Key fields to watch: daily_transcript_pulls_used vs daily_transcript_pulls_cap, daily_searches_used vs daily_searches_cap, and monthly_transcript_pulls_used vs monthly_transcript_pulls_cap. A _cap of null means unlimited. This endpoint never consumes quota.

Detect updated transcripts

curl "https://api.meshapi.app/v1/pulls?limit=100" \
  -H "X-API-Key: mesh_your_key_here"

Returns every transcript you have previously pulled. Check the has_updates field on each entry: true means Mesh has newer transcript data since your last pull (re-diarized, speaker resolved, etc.). Re-fetch those transcripts to get the latest version. This endpoint never consumes quota.

Combined Workflows#

These examples chain multiple endpoints together for real-world data collection tasks. They are written in Python for readability but the logic applies to any HTTP client. Enterprise users with contract-defined caps can adjust concurrency and batch sizes to match their quotas.

Bulk-pull all transcripts for a speaker (using batch endpoint)

Collect a speaker's event IDs, then use the batch endpoint to pull transcripts in chunks of up to 25 (enterprise) or 10 (other plans) per request.

import requests

BASE = "https://api.meshapi.app"
HEADERS = {"X-API-Key": "mesh_your_key_here"}
SPEAKER_ID = "381194fe-4856-4945-9e49-9809be82e924"

# 1. Collect every event ID for this speaker
event_ids = []
cursor = None
while True:
    params = {
        "speaker_id": SPEAKER_ID,
        "has_transcript": "true",
        "limit": 200,
    }
    if cursor:
        params["cursor"] = cursor
    resp = requests.get(f"{BASE}/v1/events", headers=HEADERS, params=params).json()
    event_ids.extend(e["event_id"] for e in resp["data"])
    cursor = resp.get("next_cursor")
    if not cursor:
        break

# 2. Batch-pull transcripts (25 at a time for enterprise, 10 for other plans)
BATCH_SIZE = 25
for i in range(0, len(event_ids), BATCH_SIZE):
    batch = event_ids[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch, "limit_per_event": 500},
    ).json()
    for entry in resp["results"]:
        print(f"{entry['event_id']}: {entry['count']} segments")
    for skip in resp["skipped"]:
        print(f"Skipped {skip['event_id']}: {skip['reason']}")
    if resp["pulls_remaining_daily"] == 0:
        print("Daily cap reached — resume tomorrow")
        break

Each unique event counts as one transcript pull per UTC day. Re-fetching the same event later in the day is free, so retries and incremental runs are safe. The batch response tells you exactly how many pulls remain so you can stop gracefully.

Pull transcripts for two speakers and merge by date

Use speaker_ids to fetch events from both speakers in a single paginated call, then batch-pull the transcripts.

SPEAKER_A = "381194fe-4856-4945-9e49-9809be82e924"
SPEAKER_B = "f47ac10b-58cc-4372-a567-0e02b2c3d479"

# 1. Fetch events for both speakers in one call using speaker_ids
events = []
cursor = None
while True:
    params = {
        "speaker_ids": f"{SPEAKER_A},{SPEAKER_B}",
        "has_transcript": "true",
        "limit": 200,
    }
    if cursor:
        params["cursor"] = cursor
    resp = requests.get(f"{BASE}/v1/events", headers=HEADERS, params=params).json()
    events.extend(resp["data"])
    cursor = resp.get("next_cursor")
    if not cursor:
        break

# Events come sorted by date (newest first) — already interleaved
event_ids = [e["event_id"] for e in events]

# 2. Batch-pull transcripts
BATCH_SIZE = 25
for i in range(0, len(event_ids), BATCH_SIZE):
    batch = event_ids[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch, "limit_per_event": 500},
    ).json()
    for entry in resp["results"]:
        event = next(e for e in events if e["event_id"] == entry["event_id"])
        speaker = event.get("primary_speaker_name", "Unknown")
        print(f"[{speaker}] {event['canonical_title']}: {entry['count']} segments")
    if resp["pulls_remaining_daily"] == 0:
        break

Quota-aware bulk pulling

The batch endpoint returns pulls_remaining_daily and pulls_remaining_monthly in every response, so you can make cap-aware decisions without extra API calls. For finer control, check GET /v1/usage before starting.

def get_remaining_pulls():
    usage = requests.get(f"{BASE}/v1/usage", headers=HEADERS).json()
    daily_cap = usage["daily_transcript_pulls_cap"]  # null = unlimited
    monthly_cap = usage["monthly_transcript_pulls_cap"]
    daily_left = (
        daily_cap - usage["daily_transcript_pulls_used"]
        if daily_cap is not None else float("inf")
    )
    monthly_left = (
        monthly_cap - usage["monthly_transcript_pulls_used"]
        if monthly_cap is not None else float("inf")
    )
    return min(daily_left, monthly_left)

budget = get_remaining_pulls()
print(f"Transcript pulls remaining: {budget}")

# Batch-pull only what fits in the remaining budget
BATCH_SIZE = 25
for i in range(0, len(event_ids), BATCH_SIZE):
    batch = event_ids[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch},
    ).json()
    for entry in resp["results"]:
        print(f"{entry['event_id']}: {entry['count']} segments")
    # The batch endpoint gracefully skips events over cap instead of 429-ing
    if resp["skipped"]:
        for skip in resp["skipped"]:
            print(f"Skipped {skip['event_id']}: {skip['reason']}")
        break

The usage endpoint never consumes quota. The batch endpoint never returns a 429 for partial over-cap — it processes what it can and skips the rest.

Build a topic corpus from search, then batch-pull full transcripts

Search for segments matching a topic across all speakers, collect the distinct event IDs from the results, then batch-pull the full transcript for each.

# 1. Search for all segments mentioning "tariffs" in press conferences
matched_event_ids = set()
cursor = None
while True:
    params = {
        "q": "tariffs",
        "event_type": "press_conference",
        "start_date": "2026-01-01",
        "limit": 200,
    }
    if cursor:
        params["cursor"] = cursor
    resp = requests.get(
        f"{BASE}/v1/segments/search", headers=HEADERS, params=params
    ).json()
    for seg in resp["data"]:
        matched_event_ids.add(seg["event_id"])
    cursor = resp.get("next_cursor")
    if not cursor:
        break

print(f"Found segments in {len(matched_event_ids)} events")

# 2. Batch-pull the full transcript for each discovered event
event_list = list(matched_event_ids)
BATCH_SIZE = 25
for i in range(0, len(event_list), BATCH_SIZE):
    batch = event_list[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch, "limit_per_event": 500},
    ).json()
    for entry in resp["results"]:
        print(f"{entry['event_id']}: {entry['count']} segments")
    if resp["pulls_remaining_daily"] == 0:
        break

Search calls count against the daily search quota, and each new event pulled counts against the transcript pull quota.

Detect and re-pull updated transcripts

Poll GET /v1/pulls to find transcripts that Mesh has re-processed since your last fetch, then batch-pull fresh copies.

# 1. Check for stale pulls
resp = requests.get(f"{BASE}/v1/pulls?limit=100", headers=HEADERS).json()
stale_ids = [p["event_id"] for p in resp["data"] if p.get("has_updates")]
print(f"{len(stale_ids)} transcripts have newer data")

# 2. Batch re-pull only the updated ones
BATCH_SIZE = 25
for i in range(0, len(stale_ids), BATCH_SIZE):
    batch = stale_ids[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch, "limit_per_event": 500},
    ).json()
    for entry in resp["results"]:
        print(f"Refreshed {entry['event_id']}: {entry['count']} segments")

Re-pulling a previously fetched event on the same UTC day does not cost an additional transcript pull.

Extract Q&A from press conferences

Transcript segments include is_question and question_group_id fields. Pull a press conference transcript and separate questions from answers to analyze what journalists are asking a speaker about.

# 1. Find all press conferences for a speaker
events = []
cursor = None
while True:
    params = {
        "speaker_id": SPEAKER_ID,
        "event_type": "press_conference",
        "has_transcript": "true",
        "limit": 200,
    }
    if cursor:
        params["cursor"] = cursor
    resp = requests.get(f"{BASE}/v1/events", headers=HEADERS, params=params).json()
    events.extend(resp["data"])
    cursor = resp.get("next_cursor")
    if not cursor:
        break

# 2. Pull each transcript and extract Q&A pairs
for event in events:
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": [event["event_id"]], "limit_per_event": 500},
    ).json()
    if not resp["results"]:
        continue
    segments = resp["results"][0]["segments"]

    questions = [s for s in segments if s["is_question"]]
    answers = [s for s in segments if not s["is_question"]]
    print(f"{event['canonical_title']}: {len(questions)} questions, {len(answers)} answer segments")

    # Group questions by question_group_id to see multi-sentence questions
    from collections import defaultdict
    groups = defaultdict(list)
    for q in questions:
        gid = q.get("question_group_id") or q["idx"]
        groups[gid].append(q["sentence_txt"])
    for gid, texts in groups.items():
        print(f"  Q: {' '.join(texts)}")

The question_group_id links multi-sentence questions into a single logical question. Segments without is_question in the same region are typically the speaker's answer.

Compare two speakers on a topic

Use the topic segments endpoint (GET /v1/topics/{slug}/segments) with different speaker filters to compare what two speakers say about the same subject. This endpoint is unmetered — it does not count as a transcript pull.

SPEAKER_A = "381194fe-4856-4945-9e49-9809be82e924"
SPEAKER_B = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
TOPIC = "nato"

def get_topic_segments(topic_slug, speaker_id):
    segments = []
    cursor = None
    while True:
        params = {"speaker_id": speaker_id, "limit": 200}
        if cursor:
            params["cursor"] = cursor
        resp = requests.get(
            f"{BASE}/v1/topics/{topic_slug}/segments",
            headers=HEADERS, params=params,
        ).json()
        segments.extend(resp["data"])
        cursor = resp.get("next_cursor")
        if not cursor:
            break
    return segments

segs_a = get_topic_segments(TOPIC, SPEAKER_A)
segs_b = get_topic_segments(TOPIC, SPEAKER_B)

print(f"Speaker A on {TOPIC}: {len(segs_a)} segments across {len(set(s['event_id'] for s in segs_a))} events")
print(f"Speaker B on {TOPIC}: {len(segs_b)} segments across {len(set(s['event_id'] for s in segs_b))} events")

# Organize by event date for chronological comparison
for seg in sorted(segs_a, key=lambda s: s.get("event_date", "")):
    print(f"  [{seg.get('event_date', '')[:10]}] {seg['sentence_txt'][:120]}")

Since topic segment browsing is unmetered, this workflow consumes zero transcript pulls and zero searches — useful for exploratory analysis before deciding what to pull in full.

Correlate transcripts with prediction market outcomes

Pull events that have Kalshi market resolutions, then fetch both the transcript and the resolution data to analyze how speech correlates with market outcomes.

# 1. Find events with transcripts (market-linked events have kalshi_tickers)
events = []
cursor = None
while True:
    params = {"has_transcript": "true", "limit": 200}
    if cursor:
        params["cursor"] = cursor
    resp = requests.get(f"{BASE}/v1/events", headers=HEADERS, params=params).json()
    events.extend(resp["data"])
    cursor = resp.get("next_cursor")
    if not cursor:
        break

market_events = [e for e in events if e.get("kalshi_tickers")]
print(f"{len(market_events)} events have linked prediction markets")

# 2. For each market-linked event, fetch resolutions and transcript
for event in market_events:
    eid = event["event_id"]

    # Get market resolutions (unmetered)
    resolutions = requests.get(
        f"{BASE}/v1/events/{eid}/resolutions", headers=HEADERS
    ).json()

    # Get transcript topics (unmetered)
    topics = requests.get(
        f"{BASE}/v1/events/{eid}/topics", headers=HEADERS
    ).json()

    print(f"\n{event['canonical_title']}")
    print(f"  Topics: {', '.join(t['slug'] for t in topics)}")
    for r in resolutions.get("resolutions", []):
        print(f"  Market: {r['market_ticker']} → {r['resolution']}")

# 3. Batch-pull the full transcripts for deeper analysis
event_ids = [e["event_id"] for e in market_events]
BATCH_SIZE = 25
for i in range(0, len(event_ids), BATCH_SIZE):
    batch = event_ids[i : i + BATCH_SIZE]
    resp = requests.post(
        f"{BASE}/v1/events/batch-segments",
        headers=HEADERS,
        json={"event_ids": batch, "limit_per_event": 500},
    ).json()
    for entry in resp["results"]:
        print(f"  {entry['event_id']}: {entry['count']} segments pulled")

Resolutions and topics are unmetered. Only the batch-segments call at the end consumes transcript pulls.

Build a speaker profile

Combine speaker details, aggregated stats, event history, and top topics into a comprehensive profile — useful for building speaker pages or research dossiers.

# 1. Speaker identity and stats (unmetered)
speaker = requests.get(
    f"{BASE}/v1/speakers/{SPEAKER_ID}", headers=HEADERS
).json()
stats = requests.get(
    f"{BASE}/v1/speakers/{SPEAKER_ID}/stats", headers=HEADERS
).json()

print(f"{speaker['canonical_name']} ({speaker['event_count']} events)")
speaking = stats["speaking_time"]
qa = stats["as_primary_speaker"]
print(f"  Total speaking time: {speaking['total_speaking_ms'] / 60000:.0f} minutes")
print(f"  Events appeared in: {speaking['events_appeared_in']}")
print(f"  Q&A events: {qa['qa_event_count']}, avg questions: {qa['avg_questions_per_event']:.1f}")

# 2. All their events (unmetered)
events = requests.get(
    f"{BASE}/v1/speakers/{SPEAKER_ID}/events", headers=HEADERS
).json()

# 3. Discover which topics this speaker covers most
from collections import Counter
topic_counts = Counter()
for event in events:
    topics = requests.get(
        f"{BASE}/v1/events/{event['event_id']}/topics", headers=HEADERS
    ).json()
    for t in topics:
        topic_counts[t["slug"]] += 1

print(f"\nTop topics:")
for slug, count in topic_counts.most_common(10):
    print(f"  {slug}: {count} events")

Every call in this workflow is unmetered — speaker details, stats, events, and event topics all fall outside the transcript pull and search quotas.

Track a topic over time

Use the topic segments endpoint with date ranges to measure how much a topic is discussed week-over-week or month-over-month.

from datetime import date, timedelta

TOPIC = "tariffs"
start = date(2026, 1, 1)
end = date(2026, 6, 1)

# Walk month by month
current = start
while current < end:
    month_end = (current.replace(day=28) + timedelta(days=4)).replace(day=1)
    if month_end > end:
        month_end = end

    segments = []
    cursor = None
    while True:
        params = {
            "start_date": current.isoformat(),
            "end_date": month_end.isoformat(),
            "limit": 200,
        }
        if cursor:
            params["cursor"] = cursor
        resp = requests.get(
            f"{BASE}/v1/topics/{TOPIC}/segments",
            headers=HEADERS, params=params,
        ).json()
        segments.extend(resp["data"])
        cursor = resp.get("next_cursor")
        if not cursor:
            break

    event_ids = set(s["event_id"] for s in segments)
    print(f"{current.strftime('%Y-%m')}: {len(segments)} segments across {len(event_ids)} events")
    current = month_end

This workflow is entirely unmetered — topic segment browsing does not consume transcript pulls or searches. Useful for building dashboards or tracking topic salience before committing pull budget.

Notes#

  • Search results are ordered by transcript position, not relevance.
  • Free-plan unlocks reset at 00:00 UTC.
  • The API key value is only visible once at creation. Store it immediately.
  • Caps are account-wide across all your API keys.