Skip to main content

Community News API Reference

The Community News API lets you access community journalism content programmatically. Build integrations, let AI agents read your communities, or automate workflows.

Machine-readable discovery:


Authentication

All authenticated endpoints require a Bearer token in the Authorization header:

Authorization: Bearer cn_your_api_key_here

Creating API Keys

  1. Log in to your account
  2. Go to Profile Settings → API Keys
  3. Click Create API Key
  4. Copy the key immediately — it's shown only once

Key Scoping

API keys inherit your role for visibility — they can be further restricted, but never widened beyond your role.

Restrictions you can apply at create time:

  • Community scope — restrict to specific communities (default: every community you're a member of)
  • Read-only — see below
  • Expiration — auto-revoke after 30/90/180/365 days (default: never)

So a platform-admin user with a read-only key still has full admin read visibility through that key, but cannot mutate anything via the v1 API. The key is a ceiling applied to your authority, not a separate role.

Read-only keys

When creating a key, you can mark it Read-only. A read-only key cannot perform POST/PUT/PATCH/DELETE requests — any mutating call returns 403. Use this for analytics scripts, CI checks, or any integration that only needs to read. Reduces the blast radius if the key leaks: an attacker holding a leaked read-only key can read what you can read, but cannot create, modify, or delete anything.

Per-key audit log

Every authenticated API request is logged with timestamp, IP address, user-agent, response status, and (on errors) the typed error class. View the last 50 requests for any key under Profile Settings → API Keys → Activity. Retained 90 days.

Regenerating a key

If you suspect a key has leaked, click Regenerate next to the key. The old key is revoked and a new one is created in the same atomic operation, with the same name, community access, expiration, and read-only flag. The new key string is shown once. Old key returns 401 immediately.


Base URL

https://alaskanews.com/api/v1

For local development: http://localhost:3200/api/v1


Response Format

Success

{
  "data": { ... },
  "next_steps": [
    { "rel": "articles", "method": "GET", "href": "/api/v1/communities/slug/articles", "description": "Browse articles" }
  ]
}

List (paginated)

{
  "data": [ ... ],
  "count": 5,
  "offset": 0,
  "limit": 20,
  "has_more": false,
  "next_steps": [ ... ]
}

Error

{
  "error": "not_found",
  "message": "Article not found: abc123",
  "suggestion": "Check the article ID and try again."
}

HTTP Status Codes

CodeMeaning
200Success
201Created
401Unauthorized — missing or invalid API key
403Forbidden — key doesn't have access to this resource
404Not found
422Validation error — check the issues field
429Rate limited — check Retry-After header
500Server error

HATEOAS: next_steps

Every response includes a next_steps array with suggested actions. Each step has:

  • rel — relationship type (e.g., "articles", "community")
  • method — HTTP method (GET, POST, PUT)
  • href — the endpoint URL
  • description — what this action does

Follow next_steps to navigate the API without memorizing endpoints.


Rate Limits

Two layers, both return 429 with a Retry-After header when exceeded.

User bucket (per account, all your keys share):

TypeLimit
Read (GET)300 requests per minute
Write (POST/PUT/PATCH/DELETE)180 requests per minute

Per-key write burst cap (new in 2026-04, defense against leaked keys):

TypeLimit
Write (POST/PUT/PATCH/DELETE)30 per 10 seconds, per key

The per-key cap is 10-second window because that's the burst velocity that matters when a key is compromised — a per-minute cap doesn't bound a 9-second destructive burst. Compute-volunteer keys (or any integration that legitimately needs more) can be provisioned with a higher per-key cap on request.


Auth tiers

Every endpoint in this document belongs to one of four tiers. Before 2026-09-09 no surface stated them, and the only way to learn which endpoints your key reached was to call them and read the failures.

TierWhat you needIf you are refused
publicnothingit is not an auth problem
api-keya cn_ bearer token (manage keys)check the key, then the community scope
api-key + editor rolea cn_ token and editor or admin membership in the target communityrequest membership from a community admin
session-onlya browser cookie session. No API key of any role reaches these.there is no upgrade to request; use the web UI

A session-only endpoint answers 403 session_auth_only when you present a bearer token and 401 when you present nothing. Both bodies carry a suggestion naming what to do instead. Ask GET /api/v1/me for the definitive list of what your own key reaches.

Endpoints for agents

These have live external callers and were documented in none of the platform's five description surfaces until 2026-09-09.

POST /api/v1/rag/query

Citation-backed retrieval over the corpus. Tier: api-key. Rate-limited on the write bucket, because it calls a model.

curl -X POST https://alaskanews.com/api/v1/rag/query \
  -H "Authorization: Bearer $CN_KEY" -H "Content-Type: application/json" \
  -d '{"query": "What did the assembly decide about the bond?", "community": "alaska-news", "synthesize": true}'

synthesize: true returns a composed answer plus the citations behind it. synthesize: false returns the raw retrieval pools (quote_pool, people_facts, history_pool, general_facts) so you can compose the answer yourself. Prefer false when you intend to quote: it is faster, cheaper, and you see exactly what was retrieved.

GET /api/v1/feed

The public front-page feed. Tier: public (no key). Parameters: limit, offset, community (slug), sort (new by default, or timeline, hot, top, ...), t, as_of, published_after, published_before, exclude.

  • new is not strictly chronological. It bins by age the way the card's relative time does (day-wide for a week, then week-wide to 30 days, then 30-day-wide) and orders by priority inside a bin. timeline orders by editorial date as an Alaska day, then priority. For one day's stories, pass published_after / published_before.
  • Paging is exact when every page of one read passes the same as_of: each article is on exactly one page. The response echoes as_of, and the next_page step carries it with every filter. has_more is exact (one row past the page is fetched).
  • The response names the newsroom in scope as community: {slug, name, timezone}, so a keyless client can count days in the newsroom's own time zone. Null when the scope spans several communities.
  • 404 for a community the caller cannot read; 422 for a date that is not ISO 8601.

Corrected 2026-09-29: this entry said "chronological" and "Tier: api-key", the route ignored community, reported has_more: false on every page, and repeated and dropped articles across pages (ten pages of 100 on prod held 978 unique). An outside client found all of it.

Coverage planning, per community

Tier: api-key + editor role for all four. These write to editorial state, so a read-only key is refused even with membership.

  • GET /api/v1/communities/{slug}/coverage/search: search the coverage surface
  • GET /api/v1/communities/{slug}/coverage/feed: coverage feed
  • POST /api/v1/communities/{slug}/coverage/synthesize-concept: synthesize a concept
  • POST /api/v1/communities/{slug}/coverage/create-source: create a content source

Source material with provenance

Tier: api-key. Built for agents in Plans 183, 190 and 283, and, until now, described nowhere. These carry the richest next_steps on the platform.

  • GET /api/v1/external-documents and /{id}: documents extracted from source material
  • GET /api/v1/external-posts and /{id}: social posts captured as source material
  • GET /api/v1/ai-discovery: discovery runs and their candidate sources

GET /api/v1/articles/{id}?include=engagement

Adds aggregated view and reaction stats to a single article (Plan 183). Tier: api-key. Opt-in, because the aggregation costs a query most callers do not want.

Transcripts, and one asymmetry worth knowing

  • GET /api/v1/transcripts: list. Tier: api-key.
  • GET /api/v1/transcript/{sourceId}/chunks: the full transcript text. Tier: public.
  • GET /api/v1/transcript/{sourceId}/speakers: who said it. Tier: session-only.

That is not a typo. The words of a public meeting are readable with no credential, and the speaker attribution for the same meeting is readable with none that an API client can hold. Recorded rather than quietly fixed, because changing either direction is an editorial decision about public records, not a code cleanup.

GET /api/v1/clips

Tier: session-only. Browsing clips takes a browser session and no API key of any role. The related media route GET /api/v1/clips/{id}/stream is public.

Two routes exist so a key-holding client is not stuck between those two facts (2026-09-10). Until they landed, the clip media was anonymously downloadable and nothing would tell a caller which ids existed, which is the same inversion as the transcript pair above: the payload public, the index not.

  • GET /api/v1/transcript/{sourceId}/clips: clips cut from one source. Tier: api-key.
  • GET /api/v1/articles/{id}/clips: clips reachable from an article, via the sources it was built from. Tier: api-key.

Every row carries download_url, which points at the public stream route, so a downloader needs one credentialed call to learn the ids and none to fetch the bytes.

The article route resolves through article_sources, not through the inline-clip nodes in the article body. Both relationships exist and they are different sets. The inline set was empty for every one of the newest 300 articles when this was measured, while their sources had clips, so keying on it would have returned nothing for every article that exists.

Note that POST /api/v1/transcript/{sourceId}/clips remains session-only while the GET beside it takes a key. Creating a clip is an editorial act performed by a person; reading the index of ones already published is not.

Endpoints

Discovery

GET /api/v1

Returns API metadata, available endpoints, and documentation links. No authentication required.

curl https://alaskanews.com/api/v1

Articles

GET /api/v1/articles

List published articles for a community.

Query Parameters:

ParamTypeRequiredDescription
communitystringYes*Community slug (*optional for foryou sort)
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
sortstringNoSort mode: hot (default), new, nearby, top, rising, controversial, foryou
tstringNoTime window for top sort: today, week (default), month, all
latnumberNoLatitude for nearby sort
lngnumberNoLongitude for nearby sort
rnumberNoRadius in miles for nearby sort (default: 50, min: 5, max: 500)
# Default (hot) sort
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?community=municipality-of-anchorage&limit=10"

# Top articles this month
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?community=municipality-of-anchorage&sort=top&t=month"

# Nearby articles within 100 miles
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?community=municipality-of-anchorage&sort=nearby&lat=61.2&lng=-149.9&r=100"

# Rising articles (gaining traction recently)
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?community=municipality-of-anchorage&sort=rising"

# Controversial articles (high engagement, mixed reactions)
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?community=municipality-of-anchorage&sort=controversial"

# Personalized feed (excludes already-read articles)
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles?sort=foryou"

POST /api/v1/articles

Create a new draft article.

Request Body:

{
  "community_id": "uuid",
  "title": "Article Title",
  "slug": "article-title",
  "content": { "type": "doc", "content": [] },
  "excerpt": "Optional excerpt"
}

GET /api/v1/articles/:id

Get a single article by ID.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/articles/uuid

GET /api/v1/articles/:id/sources

The sources this article was built from, through the article_sources junction. Returns both kinds: content_sources (a meeting recording, a filing, an RSS item) and external_documents. The junction carries a content_source_id XOR an external_document_id, so a caller reading only one of the two arrays sees a partial provenance chain.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/articles/uuid/sources

PUT /api/v1/articles/:id

Update a draft article. Same body format as POST.

POST /api/v1/articles/:id/submit

Submit a draft for peer review. No request body needed.

GET /api/v1/articles/:id/pin

Get the current active pin state for an article (both scopes). null means the article is not pinned in that scope.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/articles/uuid/pin
{
  "data": {
    "article_id": "uuid",
    "pins": {
      "home": null,
      "community": {
        "id": "uuid",
        "articleId": "uuid",
        "scope": "community",
        "pinnedBy": "uuid",
        "pinnedAt": "2026-05-16T09:25:00.000Z",
        "expiresAt": "2026-05-17T09:25:00.000Z",
        "reason": null,
        "unpinnedAt": null,
        "unpinnedBy": null
      }
    }
  }
}

POST /api/v1/articles/:id/pin

Pin an article to the top of the feed for 24 hours. Re-pinning an already-pinned article extends the window by another 24h (update in place). A community-scope pin also makes the article eligible for the homepage hero / top-story slot.

Request Body (all fields optional):

{
  "scope": "community",
  "reason": "Breaking — lead story"
}
FieldTypeDefaultDescription
scopestringcommunitycommunity (pins within the article's community) or home (site-wide top story)
reasonstringnullOptional audit note

Authorization (identical to the web UI Pin control):

  • scope=community → caller must be admin, editor, or platform_admin on the article's community. A community-scoped API key may only pin articles inside its allowed communities.
  • scope=home → caller must be platform_admin.
# Pin within the article's community (default)
curl -X POST -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/articles/uuid/pin

# Pin site-wide as the homepage top story (platform-admin keys only)
curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"scope":"home","reason":"Lead story"}' \
  https://alaskanews.com/api/v1/articles/uuid/pin

Returns 201 with the pin record.

DELETE /api/v1/articles/:id/pin

Remove the active pin. Scope via the scope query param (defaults community). Idempotent — returns 200 even if there was no active pin (unpinned: false).

curl -X DELETE -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/articles/uuid/pin?scope=community"
{ "data": { "article_id": "uuid", "scope": "community", "unpinned": true } }

Communities

GET /api/v1/communities

List communities accessible to your API key. Supports sorting.

Query Parameters:

ParamTypeRequiredDescription
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
sortstringNoSort mode: hot (default), new, nearby, top, rising, controversial, foryou
tstringNoTime window for top sort: today, week (default), month, all
latnumberNoLatitude for nearby sort
lngnumberNoLongitude for nearby sort
rnumberNoRadius in miles for nearby sort (default: 50, min: 5, max: 500)
curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities

# Communities near Anchorage
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/communities?sort=nearby&lat=61.2&lng=-149.9"

GET /api/v1/communities/:slug

Get community details by slug.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities/municipality-of-anchorage

GET /api/v1/communities/:slug/articles

List published articles in a community. Supports sorting.

ParamTypeRequiredDescription
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
sortstringNoSort mode: hot (default), new, nearby, top, rising, controversial, foryou
tstringNoTime window for top sort: today, week (default), month, all
latnumberNoLatitude for nearby sort
lngnumberNoLongitude for nearby sort
rnumberNoRadius in miles for nearby sort (default: 50, min: 5, max: 500)

GET /api/v1/communities/:slug/members

List members of a community.


Memberships

GET /api/v1/memberships

List your community memberships and roles.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/memberships

POST /api/v1/memberships

Join a community.

{
  "community_slug": "fairbanks-community-news"
}

Search

GET /api/v1/search

Full-text search across articles, communities, and users.

ParamTypeRequiredDescription
qstringYesSearch query
typestringNoFilter: "all", "articles", or "communities" (default: "all")
limitnumberNoMax results (default: 20, max: 100)
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/search?q=budget&type=articles"

Reviews

GET /api/v1/articles/:id/reviews

List reviews for an article.

POST /api/v1/articles/:id/reviews

Submit a review.

{
  "decision": "approve",
  "comment": "Optional review comment"
}

Valid decisions: approve, request_changes, reject. If enough approvals are reached, the article is auto-published.

Policy hold on approval. If AI review held the article because it presents an identifiable private person as accused of wrongdoing, an approve is refused with 422 and a message carrying the editor's questions. Resend with "acknowledge_hold": true to approve anyway. The same applies to POST /api/v1/articles/:id/publish.


Comments

GET /api/v1/articles/:id/comments

List comments on an article.

POST /api/v1/articles/:id/comments

Add a comment.

{
  "content": "Great article!"
}

PUT /api/v1/articles/:id/comments/:commentId

Edit a comment (within 1 hour of creation).

DELETE /api/v1/articles/:id/comments/:commentId

Delete your own comment.


Reactions

GET /api/v1/articles/:id/reactions

Get reaction counts and your reactions for an article.

POST /api/v1/articles/:id/reactions

Toggle a reaction (add if not exists, remove if exists).

{
  "emoji": "👍"
}

Valid emojis: 👍 👎 ❤️ 🔥 👀 😂 🎯 💯


Bookmarks

POST /api/v1/articles/:id/bookmarks

Toggle bookmark on an article (add if not bookmarked, remove if bookmarked).


Tags

The live subject vocabulary. Use these rather than Topics below, which serves a taxonomy retired in April 2026 and reports article_count: 0 on every entry.

GET /api/v1/tags

List tags with per-tag article counts. Public; no key required.

Query Parameters:

ParameterTypeDescription
qstringsubstring match on tag name
categorystringfilter by tag category
curl https://alaskanews.com/api/v1/tags?q=fisheries

GET /api/v1/tags/:slug/articles

Articles carrying this tag and its descendants, so a parent tag returns the subtree rather than only exact matches. Public.

Query Parameters:

ParameterTypeDescription
sortstringordering
tstringtime window

POST /api/v1/tags/:slug/aliases

Add an alias to a tag. Idempotent, with case-folded dedup, so re-posting an alias that already exists succeeds rather than erroring.

DELETE /api/v1/tags/:slug/aliases?alias=...

Remove an alias. Aliases are stored inline on tags.aliases as a TEXT[] and have no stable id, which is why the alias to remove is passed as a query parameter rather than a path segment.


People

GET /api/v1/persons

Bounded list of people the newsroom tracks.

Query Parameters:

ParameterTypeDescription
qstringsubstring match on name
communitystringcommunity slug, resolved to an id server-side
rolestringfilter by role

POST /api/v1/persons

Create a person. Requires content_creator or above in the target community.

GET /api/v1/persons/:id

A single person. PATCH updates one.

GET /api/v1/persons/:id/articles

Published articles this person appears in. Public. The counterpart to GET /api/v1/tags/:slug/articles, returning the same union shape.


Calendar

GET /api/v1/calendar

Article events for calendar display.

Query Parameters:

ParameterTypeDescription
communitystringcommunity slug
type / typesstringone event type, or several
categoriesstringfilter by category
start / endISO datedate range
limit / offsetintegerpagination

The response carries a next_steps entry with the next window's real ISO dates computed from the range you asked for, so paging forward needs no date arithmetic on your side.


Events

GET /api/v1/events/:id/articles

Articles linked to this event. Public for approved events, and published articles only. Editors and the original submitter see every linked article regardless of status, so the same URL returns different sets to different callers by design.

POST /api/v1/events/:id/articles

Link an article to an event.


Media

GET /api/v1/stock-photos

Server-side proxy to Pexels and Unsplash, so their API keys stay on the server. Returns simplified photo objects.

Query Parameters:

ParameterTypeDescription
querystringsearch terms
sourcestringpexels or unsplash
per_pageintegerresults per page
pageintegerpage number

Topics

Deprecated. Retired in April 2026 and replaced by Tags above. Every topic reports article_count: 0. Documented because the routes still answer, not because you should call them.

GET /api/v1/topics

List all topics with article stats. Supports sorting.

Query Parameters:

ParamTypeRequiredDescription
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
sortstringNoSort mode: hot (default), new, top
tstringNoTime window for top sort: today, week (default), month, all
curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/topics

GET /api/v1/topics/:slug

Get topic details and published articles for a topic.

Query Parameters:

ParamTypeRequiredDescription
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
sortstringNoSort mode: hot (default), new, top
tstringNoTime window for top sort
curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/topics/government

Co-authors

GET /api/v1/articles/:id/coauthors

List co-authors for an article.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/articles/uuid/coauthors

POST /api/v1/articles/:id/coauthors

Invite a co-author to an article.

{
  "user_id": "uuid"
}

PUT /api/v1/articles/:id/coauthors/:coauthorId

Accept or decline a co-author invitation. Only the invited user can respond.

{
  "status": "accepted"
}

Valid statuses: accepted, declined.


Content Sources

Content sources represent all types of input content (video, audio, email, web scrape, RSS, PDF, manual text) that can be processed through the pipeline into articles.

GET /api/v1/communities/:slug/sources

List content sources for a community, newest-added first (by created_at, then id).

To read a complete set, follow next_cursor. Each page returns next_cursor, and passing it back as cursor returns the rows after the last one you received, until next_cursor is null. Every source that exists for the whole walk is returned exactly once, however much the pipeline changes sources meanwhile. A source created, or moved into or out of your status filter, during the walk may or may not appear. offset still works, but a source changing status mid-walk shifts every later offset by one.

Until 2026-09-13 this listing was ordered by updated_at, which the pipeline rewrites while a client pages, so paging it to the end could repeat one source and silently skip another.

Query Parameters:

ParamTypeRequiredDescription
typestringNoFilter by source type: video, audio, email, web_scrape, rss, pdf, manual, media_upload, video_download, livestream, transcript_import, podcast, draft, concept
statusstringNoFilter by status: pending, scheduled, downloading, transcribing, classification, tagging, distillation, analyzing, face_clustering, concepts, drafting, completed, failed, skipped, processing, classifying, classified, drafted
limitnumberNoMax results (default: 20, max: 100)
cursorstringNoThe next_cursor from the previous page, unchanged. Cannot be combined with offset (422 validation_error, as are an invalid cursor and an unknown type or status)
offsetnumberNoSkip first N results (default: 0)

Response: the list envelope, plus total (sources matching the filters when the page was read, whatever the cursor) and next_cursor (null on the last page). has_more is next_cursor !== null, and offset is null on a page reached by cursor. When there is a next page, next_steps includes a next_page step whose href already carries the cursor and your filters.

# The newest sources
curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources

# Filter by type
curl -H "Authorization: Bearer cn_..." \
  "https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources?type=email&status=classified"

# Every completed video: follow next_cursor until it is null
url="https://alaskanews.com/api/v1/communities/alaska-news/sources?type=video&status=completed&limit=100"
cursor=""
while :; do
  page=$(curl -s -H "Authorization: Bearer cn_..." "$url${cursor:+&cursor=$cursor}")
  echo "$page" | jq -r '.data[].id'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

POST /api/v1/communities/:slug/sources

Create a new content source.

Request Body:

{
  "source_type": "manual",
  "title": "City Council Meeting Notes",
  "source_url": "https://example.com/meeting",
  "body_text": "The council voted 7-4 to approve...",
  "author_name": "Jane Reporter"
}
FieldTypeRequiredDescription
source_typestringYesOne of: video, audio, email, web_scrape, rss, pdf, manual
titlestringNoSource title or headline
source_urlstringNoURL of the original content
body_textstringNoPlain text content
body_htmlstringNoHTML content (for emails or web scrapes)
author_namestringNoOriginal author or sender

GET /api/v1/communities/:slug/sources/:id

Get source details including transcript chunks and linked drafted articles.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources/uuid

Response includes: Source metadata, transcript_chunks array (timestamped text segments), and drafted_articles array (articles generated from this source).

POST /api/v1/communities/:slug/sources/:id/classify

Trigger LLM classification for a content source. Uses Claude to analyze the source content and extract structured metadata including category, topics, entities, priority, and newsworthiness assessment.

curl -X POST -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources/uuid/classify

Response: Returns the classification result with category, topics, priority, is_newsworthy, entities, summary, and suggested_headline.

POST /api/v1/communities/:slug/sources/:id/draft

Trigger article drafting from a content source. Creates a pipeline run for the drafting stage.

curl -X POST -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources/uuid/draft

Response (201 Created):

{
  "data": {
    "run_id": "uuid",
    "source_id": "uuid",
    "stage": "drafting",
    "status": "pending"
  }
}

POST /api/v1/communities/:slug/sources/:id/activate

Activate a source once its payload is in place, which is the step that releases it into the pipeline.

For a podcast source, this is called after the audio bytes have been PUT to the signed URL that source-creation returned; it verifies the audio object actually exists before activating, so a failed upload does not produce a source that references nothing.


User Profile

GET /api/v1/me

Get the authenticated user's profile, including memberships with roles and community names.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/me

Response includes: id, display_name, bio, avatar_url, location, latitude, longitude, news_radius_miles, timezone, website_url, social_links, is_public, created_at, and memberships array (each with role, reputation_score, is_muted, and nested communities object).

PUT /api/v1/me

Update the authenticated user's profile.

Request Body (all fields optional):

{
  "display_name": "Jane Reporter",
  "bio": "Local journalist covering city council",
  "location": "Anchorage, AK",
  "latitude": 61.2181,
  "longitude": -149.9003,
  "news_radius_miles": 50,
  "timezone": "America/Anchorage",
  "website_url": "https://example.com",
  "social_links": { "twitter": "@jane" },
  "is_public": true
}

At least one field must be provided.


User Bookmarks

GET /api/v1/me/bookmarks

List the authenticated user's bookmarked articles with community info.

Query Parameters:

ParamTypeRequiredDescription
limitnumberNoMax results (default: 20, max: 100)
offsetnumberNoSkip first N results (default: 0)
curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/me/bookmarks

Each item includes article details (id, title, slug, excerpt, published_at, engagement counts) plus bookmarked_at timestamp and nested communities info.


API Keys

POST /api/v1/api-keys

Create a new API key programmatically. The full key is returned only once at creation time.

Request Body:

{
  "name": "My Integration",
  "community_ids": ["uuid1", "uuid2"],
  "expires_at": "2027-01-01T00:00:00Z"
}
FieldTypeRequiredDescription
namestringYesDescriptive name for the key
community_idsstring[]NoScope to specific communities (default: all)
expires_atstringNoISO 8601 expiration date (default: never)
curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"name": "CI Pipeline"}' \
  https://alaskanews.com/api/v1/api-keys

Response (201 Created):

{
  "data": {
    "key": "cn_a1b2c3...",
    "prefix": "cn_a1b2",
    "name": "CI Pipeline"
  }
}

Pagination

All list endpoints support pagination with offset and limit query parameters:

ParamTypeDefaultDescription
limitnumber20Results per page (max: 100)
offsetnumber0Number of results to skip

Response includes has_more: true when more results are available.

Offset paging cannot promise a complete set when the list changes while you read it: a row that moves or leaves the list shifts every later offset, so a page can repeat a row and the walk can silently miss another. Endpoints that page by cursor return next_cursor; pass it back as cursor until it is null to read every row exactly once. Today that is GET /api/v1/communities/:slug/sources.


Notifications

List Notifications

GET /api/v1/notifications?limit=20&offset=0&unread=true

Returns paginated notifications for the authenticated user. Use unread=true to filter to unread only.

Mark Notification as Read

PATCH /api/v1/notifications/:id

Marks a single notification as read. Send a JSON body with { "is_read": true }.

Mark All as Read

POST /api/v1/notifications/mark-all-read

Marks all unread notifications as read for the authenticated user.


Content Reporting

Report an Article

POST /api/v1/articles/:id/report

Report an article for violating community guidelines.

{
  "reason": "misinformation",
  "description": "Optional details about the report"
}

Valid reasons: spam, harassment, misinformation, off_topic, hate_speech, other.

Report a Comment

POST /api/v1/articles/:id/comments/:commentId/report

Report a comment. Same request body format as article reports.


Moderation (Admin Only)

List Content Reports

GET /api/v1/communities/:slug/moderation/reports?status=pending&limit=20&offset=0

List content reports for a community. Requires admin role. Supports status filter (pending, reviewed, action_taken, dismissed) and pagination.

Resolve a Report

PUT /api/v1/communities/:slug/moderation/reports/:id

Resolve a content report.

{
  "status": "dismissed",
  "note": "Optional resolution note"
}

Valid statuses: reviewed, action_taken, dismissed.


Voice Profiles

Journalist voice profiles capture writing style for AI-powered drafting and refinement. Profiles are versioned so voice evolution can be tracked over time.

Get Voice Profile

GET /api/v1/voice-profile

Returns the authenticated user's active voice profile and full version history.

Response:

{
  "data": {
    "active": {
      "id": "uuid",
      "user_id": "uuid",
      "version": 2,
      "voice_summary": "Lead with the key decision and vote count...",
      "dimensions": {
        "avg_sentence_length": 18,
        "vocabulary_grade": 8,
        "formality": 4,
        "person": "third",
        "uses_contractions": false,
        "tone": "neutral and informative",
        "lead_style": "decision-first",
        "paragraph_style": "short punchy",
        "closing_pattern": "call-to-action",
        "closest_style": "ap_wire",
        "signature_patterns": ["opens with specific vote counts", "..."],
        "never_do": ["Never use em dashes", "Never use rhetorical questions"],
        "source": "manual"
      },
      "is_active": true,
      "is_public": true,
      "created_at": "2026-03-27T..."
    },
    "history": [
      { "id": "...", "version": 2, "..." : "..." },
      { "id": "...", "version": 1, "..." : "..." }
    ]
  }
}

Calibrate Voice Profile

POST /api/v1/voice-profile

Analyze writing samples to create a new voice profile version. If a current profile exists, it's used as context so the AI builds on previous calibrations and manual edits.

Request body:

{
  "sample_texts": [
    "The Anchorage Assembly voted 7-4 Tuesday night...",
    "A second writing sample..."
  ]
}
  • 1-5 samples, minimum 200 characters total, maximum 50,000 characters total
  • Each sample is truncated to 10,000 characters

Response: Returns the newly created voice profile version (201 Created).

Update or Activate Voice Profile

PUT /api/v1/voice-profile/:id

Either activate a previous version or manually update the voice profile.

Activate a version:

{
  "action": "activate"
}

Manual update (creates a new version tagged "manual"):

{
  "voice_summary": "Lead with the key decision...",
  "dimensions": {
    "tone": "warm and factual",
    "formality": 3,
    "never_do": ["Never use em dashes", "Never use passive voice"]
  }
}

Dimensions are merged with the current profile -- only the fields you provide are overridden.

Delete Voice Profile Version

DELETE /api/v1/voice-profile/:id

Delete a non-active voice profile version. The active version cannot be deleted -- activate a different version first.

Response:

{
  "data": { "deleted": true }
}

Voice Profile Integration

When a voice profile is active, it's automatically injected into:

  • Article refinement -- the "Apply Standards" / refine feature uses your voice
  • Pipeline drafting -- automated article generation from meeting transcripts matches your voice
  • Article tracking -- articles created with a voice profile store the voice_profile_id for attribution

API Key Management

Manage your API keys at /profile/settings. You can:

  • Create keys with a descriptive name
  • Scope keys to specific communities
  • Set expiration (30 days, 90 days, 6 months, 1 year, or never)
  • Revoke keys that are no longer needed

Keys are hashed and stored securely. The full key is shown only once at creation.


Compute Worker API

Compute Workers are volunteers who process transcription jobs using their residential internet connections. These endpoints are used by the Electron Worker App.

Worker Profile

GET /api/v1/worker/profile

Get the authenticated worker's profile and statistics.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/worker/profile

Response:

{
  "data": {
    "id": "uuid",
    "user_id": "uuid",
    "display_name": "Jane Worker",
    "status": "approved",
    "trust_level": "trusted",
    "quality_score": 92,
    "jobs_completed": 25,
    "jobs_failed": 1,
    "last_active_at": "2026-03-27T...",
    "communities": [
      { "id": "uuid", "name": "Municipality of Anchorage", "slug": "municipality-of-anchorage" }
    ]
  }
}

Job Queue

GET /api/v1/worker/jobs/available

Get the next available job for the authenticated worker.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/worker/jobs/available

Response: Returns a job object if one is available, or { "data": null } if no jobs are pending.

POST /api/v1/worker/jobs/:id/claim

Claim a specific job. Jobs are atomically claimed to prevent race conditions.

curl -X POST -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/worker/jobs/uuid/claim

Response (200):

{
  "data": {
    "id": "uuid",
    "url": "https://youtube.com/watch?v=...",
    "platform": "youtube",
    "job_type": "vod",
    "status": "processing",
    "claimed_at": "2026-03-27T..."
  }
}

Job Processing

POST /api/v1/worker/jobs/:id/heartbeat

Send a heartbeat to indicate the job is still being processed. Workers should send heartbeats every 30 seconds.

curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"progress": 0.5, "message": "Processing chunk 3 of 6"}' \
  https://alaskanews.com/api/v1/worker/jobs/uuid/heartbeat

Response:

{
  "data": {
    "ok": true,
    "cancelled": false,
    "cancellation_message": null
  }
}

If cancelled: true, the worker should stop processing immediately.

POST /api/v1/worker/jobs/:id/chunks

Upload an audio chunk with optional keyframe and AI-generated frame description.

curl -X POST -H "Authorization: Bearer cn_..." \
  -F "chunk=@chunk_0.mp3" \
  -F "chunk_index=0" \
  -F "start_offset_seconds=0" \
  -F "end_offset_seconds=300" \
  -F "frame=@frame_0001.jpg" \
  -F "frame_hash=f80f07070f1f1f7f" \
  -F "frame_description=Speaker at podium with slide showing budget chart..." \
  https://alaskanews.com/api/v1/worker/jobs/uuid/chunks
FieldTypeRequiredDescription
chunkfileYesMP3 audio file (max 10MB)
chunk_indexnumberYesZero-based chunk index
start_offset_secondsnumberYesStart time in seconds
end_offset_secondsnumberYesEnd time in seconds
framefileNoJPEG keyframe captured at chunk start (Plan 111)
frame_hashstringNo16-char hex perceptual hash for dedup. If no frame binary, server looks up matching hash.
frame_descriptionstringNoAI-generated description of frame content from local Ollama vision model (Plan 112)

POST /api/v1/worker/jobs/:id/complete

Mark a job as completed.

curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"chunks_uploaded": 6}' \
  https://alaskanews.com/api/v1/worker/jobs/uuid/complete

POST /api/v1/worker/jobs/:id/fail

Report a job failure.

curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"error": "Download failed: video unavailable"}' \
  https://alaskanews.com/api/v1/worker/jobs/uuid/fail

Livestream Jobs

GET /api/v1/worker/jobs/:id/live-status

Get the current status of a livestream job.

curl -H "Authorization: Bearer cn_..." \
  https://alaskanews.com/api/v1/worker/jobs/uuid/live-status

POST /api/v1/worker/jobs/:id/transcript

Submit a partial transcript for a livestream job.

curl -X POST -H "Authorization: Bearer cn_..." \
  -H "Content-Type: application/json" \
  -d '{"text": "The meeting will now come to order...", "timestamp": 120}' \
  https://alaskanews.com/api/v1/worker/jobs/uuid/transcript

Admin: Worker Management

These endpoints require platform admin access.

List Workers

GET /api/v1/workers?status=approved&trust_level=trusted&limit=20&offset=0

Query Parameters:

ParamTypeDescription
statusstringFilter by status: pending, approved, suspended
trust_levelstringFilter by trust level: new, trusted, verified
limitnumberMax results (default: 20)
offsetnumberSkip first N results

Get Worker Details

GET /api/v1/workers/:id

Returns worker profile, communities, recent jobs, and spot check history.

Approve Worker

POST /api/v1/workers/:id/approve

Approve a pending worker application.

Suspend Worker

POST /api/v1/workers/:id/suspend
{
  "reason": "Quality issues - multiple failed spot checks"
}

Reinstate Worker

POST /api/v1/workers/:id/reinstate

Reinstate a suspended worker.


Admin: Job Management

These endpoints require platform admin access.

List Jobs

GET /api/v1/jobs?status=processing&platform=youtube&limit=20&offset=0

Query Parameters:

ParamTypeDescription
statusstringFilter by status: pending, processing, completed, failed, cancelled
platformstringFilter by platform: youtube, vimeo, facebook, etc.
job_typestringFilter by type: vod, livestream
worker_idstringFilter by assigned worker
limitnumberMax results (default: 20)
offsetnumberSkip first N results

Create Job

POST /api/v1/jobs
{
  "community_id": "uuid",
  "url": "https://youtube.com/watch?v=...",
  "priority": 5,
  "job_type": "vod"
}

Get Job Details

GET /api/v1/jobs/:id

Returns job details including chunks and transcription status.

Update Job Priority

PATCH /api/v1/jobs/:id
{
  "priority": 10
}

Cancel Job

POST /api/v1/jobs/:id/cancel
{
  "message": "Video is no longer available"
}

Retry Failed Job

POST /api/v1/jobs/:id/retry

Force retry a failed job by returning it to the queue.