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:
- /api/v1 — JSON discovery endpoint (no auth required)
- /llms.txt — plain-text quick reference for LLMs
- /.well-known/agent-card.json — structured capability manifest
Authentication
All authenticated endpoints require a Bearer token in the Authorization header:
Authorization: Bearer cn_your_api_key_here
Creating API Keys
- Log in to your account
- Go to Profile Settings → API Keys
- Click Create API Key
- 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."
}
A 422 also says which parameters were wrong, and points at this documentation and the OpenAPI description (since 2026-10-10):
{
"error": "validation_error",
"message": "Invalid query: windowHours: Too big: expected number to be <=168",
"issues": [{ "path": "windowHours", "message": "Too big: expected number to be <=168" }],
"suggestion": "Correct the request as the message describes and send it again. The API documentation lists each endpoint's parameters.",
"next_steps": [
{ "rel": "documentation", "method": "GET", "href": "/docs/api", "description": "View full API documentation" },
{ "rel": "service-desc", "method": "GET", "href": "/api/v1/openapi.json", "description": "Machine-readable API description (OpenAPI)" }
]
}
issues is present when the route checked named fields; code when the refusal has a
machine-readable name.
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — key doesn't have access to this resource |
| 404 | Not found |
| 422 | Validation error: message and issues say what to correct, and next_steps links this documentation |
| 429 | Rate limited — check Retry-After header |
| 500 | Server 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 URLdescription— 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):
| Type | Limit |
|---|---|
| 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):
| Type | Limit |
|---|---|
| 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.
| Tier | What you need | If you are refused |
|---|---|---|
public | nothing | it is not an auth problem |
api-key | a cn_ bearer token (manage keys) | check the key, then the community scope |
api-key + editor role | a cn_ token and editor or admin membership in the target community | request membership from a community admin |
session-only | a 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, r (miles, for nearby).
newis 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.timelineorders by editorial date as an Alaska day, then priority. For one day's stories, passpublished_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 echoesas_of, and thenext_pagestep carries it with every filter.has_moreis 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. nearbyranks by the signed-in reader's own location, read on the server from their profile with their saved radius (2026-10-10).roverrides the radius for that view and thenext_pagestep carries it. Coordinates are never taken from the URL, so a keyless call tosort=nearbyhas nothing to rank by and falls back to thehotorder.- 404 for a
communitythe 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 surfaceGET /api/v1/communities/{slug}/coverage/feed: coverage feedPOST /api/v1/communities/{slug}/coverage/synthesize-concept: synthesize a conceptPOST /api/v1/communities/{slug}/coverage/create-source: create a content source
Pronunciations, per community
How the community's audio says names the voice gets wrong (2026-10-12): each rule is an
ElevenLabs alias, the platform pushes the whole list on every change, and renders pin the
version that comes back. Reading the list takes any api-key; everything else is
api-key + editor role in that community. Writes answer with
dictionary: { dictionary_id, version_id, live }; live: false means the change is saved
and the dictionary is behind it, and push catches it up.
GET /api/v1/communities/{slug}/pronunciations: the words, how each is said, and the dictionary renders pinPOST /api/v1/communities/{slug}/pronunciations: add{ word, alias, note }, or{ words: [...] }in one push (409 for a word already listed in any case)PATCH /api/v1/communities/{slug}/pronunciations/words/{word}: change{ alias?, note? }DELETE /api/v1/communities/{slug}/pronunciations/words/{word}: remove a wordPOST /api/v1/communities/{slug}/pronunciations/push: push the list again after a failed pushPOST /api/v1/communities/{slug}/pronunciations/preview: hear{ word, alias, sentence? }without and with the rule, and where the word is already saidGET /api/v1/communities/{slug}/pronunciations/history: every change, who made it and when
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-documentsand/{id}: documents extracted from source material. Tier:api-key + editor rolesince 2026-10-04 (an editor or admin of the document's community; the list covers those communities). Since 2026-10-22 a caller who is an editor or admin nowhere gets, from the list only, the documents they uploaded or submitted themselves, so a reporter can find their own files to attach to a story.GET /{id}also returns, since 2026-10-11,files(each file the document lists:id,url,title,fetch_status;idandfetch_statusare null for a file declared and not stored yet),linked_from(the documents that list it, when it is itself a file:id,title,url,document_type) andgroup(null, or the other documents of the same meeting or case:group_key, an exactcount, up to 25membersoldest first withid,title,document_type,captured_at, andcappedwhen there are more).next_stepsnames up to three of each. Until 2026-10-11 this response nested the document underdata.data; it isdataitself now.POST /api/v1/external-documents: submit a document (multipart: afileor atextfield, pluscommunity,url,titleand a requiredpublished_at). Requires admin membership in the community. Processed as a crawled document is: stored, extracted, summarised, tagged and embedded; events are extracted only whenpublished_atis within 30 days. 201 on create; a resubmission of the same URL or bytes returns the existing document with 200 (result: duplicate), and a document the platform already held becomes visible to article research once submitted. 409 when another community holds it (permanent), or withcode: submission_in_progresswhile another request is processing the same URL (retry in a minute). 422 for a document that cannot be used, an upload over the limit (refused fromContent-Lengthbefore it is read) or a futurepublished_at; a 500 is the platform's own failure and worth retrying. Built for a batch caller: 429 withRetry-Afterwhen rate limited, or withcode: llm_budget_reservedonce rolling 24-hour LLM spend passes 80% of the daily budget.PUT /api/v1/external-documents/{id}/chunks: replace a submitted document's passages with a complete JSON set,{ "chunks": [{ content, kind?, start_time?, end_time? }] }, up to 2,000,kindpassage(default) orsummary, times in seconds and optional. Requires admin membership, and a document an admin submitted. Each chunk is embedded in the background and reaches drafts as a passage to paraphrase, never to quote. Sending the same set again changes nothing and an unchanged chunk keeps its embedding, so a re-export is a re-send. Send chunks only to the document the POST answered with for the URL you posted: a content duplicate can name another URL's document, and the POST offers this PUT as a next step only when the URL matches. Not limited to any document type; the first use isdocument_type=video.GET /api/v1/external-documents/{id}/chunks: the passages in order with whether each is embedded yet, keyset-paged (after,limit). Editors and admins.DELETE /api/v1/external-documents/{id}: withdraw a submitted document with its chunks, tags, links, the events it alone sourced and its stored file. Admin; refused for a crawled document and while an article cites it or carries it as an attached document (409document_cited).PATCH /api/v1/external-documents/{id}: set{ visibility, research_enabled }(2026-10-16).visibilityisprivate(default),public(readable, not indexed) orindexed, in effect only while a published article carries the document;research_enabled: falsekeeps it out of drafting research. An editor or admin of its community, or anyone who can edit an article it is attached to.third_party_mediaandevent_listingcannot be made readable. A readable result carriespersonal_data, what its text holds that looks like personal data. The GET returns both fields.POST /api/v1/external-documents/merge: fold one document into another that is the same document,{ "keep_id", "drop_id", "commit"? }. Every reference todrop_id(source links, article and event sources, tags, people, triage overrides) moves tokeep_id, keepingkeep_id's own row where the two collide, thendrop_idis deleted. Platform admin. Dry unlesscommit: true, and the dry run reports per table what a commit would move. 404 when either document does not exist, 422 when they are the same.GET /api/v1/source-documents?source_id=: the documents linked to one source, each with the status and failure reason of the stored document behind it.POST /api/v1/source-documents/reconcile(platform admin) corrects links against their stored documents without fetching.GET /api/v1/external-postsand/{id}: social posts captured as source materialGET /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
Which meetings any of these routes serve (2026-10-12): a meeting a reader may see is one
that is not hidden and whose processing did not fail. Every route below, the clip routes and
the transcript pages answer 404 for any other meeting, except to the community's editors,
journalists and admins, who keep access through the same routes. A published article that
cites such a meeting keeps the citation (title, original URL, author, date) in GET /api/v1/articles/{id}/sources, with the fields derived from our processing (status,
classification, priority, topic_names, location, category, is_newsworthy) returned
as null, and its clips leave GET /api/v1/articles/{id}/clips.
GET /api/v1/transcripts: list. Tier:api-key.POST /api/v1/transcripts: import a recording's transcript (JSON:community,video_url,title,recorded_at, and up to 2,000chunksof{content, start_time, end_time}in seconds; no speaker labels). Requires admin membership in the community. Stored as an archival source: classified, tagged and embedded for search and retrieval, never drafted. A video already held answers 409 (existing_idwhen it is in your community). Same 429 behaviour as the document POST.GET /api/v1/transcript/{sourceId}/chunks: the full transcript text, plusframes: the video frames of the job that produced the transcript, each{frameIndex, startSeconds, imageUrl, description}(2026-10-04; the items carriedchunkIndex,endSecondsandframeHashbefore, and took the newest job's frames). 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:
| Param | Type | Required | Description |
|---|---|---|---|
| community | string | Yes* | Community slug (*optional for foryou sort) |
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip first N results (default: 0) |
| sort | string | No | Sort mode: hot (default), new, nearby, top, rising, controversial, foryou |
| t | string | No | Time window for top sort: today, week (default), month, all |
| lat | number | No | Latitude for nearby sort |
| lng | number | No | Longitude for nearby sort |
| r | number | No | Radius 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.
Where the story is comes back at the precision a reader is shown (since 2026-10-06): location
names a town, else a region, never a street address; latitude and longitude are rounded to two
decimal places, about a kilometre; place_id is returned only when a person set the location. An
editor's location is returned as typed.
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
Save an article, as the editor's save button does. Accepts title, slug, content,
excerpt, the cover fields (cover_image_url, cover_image_credit, cover_image_source,
cover_image_license, cover_community_photo_id, cover_image_locked); limits are in the OpenAPI
operation saveArticleDraft. A cover_image_url states all eight cover columns at once and locks
the cover unless cover_image_locked is false; null or "" clears the cover and unlocks it; a
changed URL is validated (422): a frame must be one of this article's own, and a storage path must be on the
project's own storage host. A cover_community_photo_id must name the photo that is the cover, the one whose URL
is sent or, with no URL, the one that already is the cover (422 otherwise). A new slug on a published article keeps
the old URL as a redirect.
published_at and community_id pass validation but are not saved by this route; set
published_at with PATCH. Validation failures are 422.
PATCH /api/v1/articles/:id
Update only the fields named in the body. Narrower than PUT: a fixed list of
fields is accepted, and any other field, or an empty body, is refused with 422
naming it.
Request Body (send only the fields to change):
{
"excerpt": "A corrected one-line summary.",
"is_hidden": false
}
| Field | Who may set it | Notes |
|---|---|---|
title, content, excerpt, tldr, tldr_format, is_hidden | author, accepted co-author, or community editor/admin | content is Tiptap JSON, at most 2MB. tldr_format is sentence, bullets or lines. |
cover_image_url, cover_image_credit, cover_image_source, cover_image_license | author, accepted co-author, or community editor/admin | See Cover below. |
published_at | community editor/admin, or platform admin | ISO 8601 string, or null. |
youtube_video_id, youtube_uploaded_at | community editor/admin, or platform admin | youtube_video_id is an 11-character YouTube video id, or null to clear it. |
location, city, state, latitude, longitude, place_id | community editor/admin, or platform admin | All six together; see Location below. |
The editor-only fields need that role whoever wrote the article. A body mixing
both kinds must satisfy both checks. A miss is 403.
Location. Naming any location field requires all six, using null for
anything unknown; a partial set is 422 listing the missing fields.
latitude and longitude must be numbers or null. A changed location is
recorded as the caller's choice, so the pipeline does not refine it afterwards.
Cover. A non-empty cover_image_url states the whole cover in one write:
the URL, credit, source and licence as sent (null when not sent), no AI prompt,
no community photo link, and a lock that keeps the pipeline from replacing it. A
URL that differs from the current one must be one this article may use (a frame
or photo the caller can reach); otherwise 422. "cover_image_url": null
clears the cover and its attribution and removes the lock. Credit, source or
licence sent without cover_image_url, or with the current cover's own URL,
correct the attribution of the current cover as given and change nothing else
(its provider, AI prompt, photo link and lock stay). cover_community_photo_id
is accepted only as null, to clear a stale photo link; a photo cover is set
with PUT.
A changed title or content is saved to the article's revision history. Only
a change to title, content, excerpt or tldr records the caller as the
article's last editor.
curl -X PATCH -H "Authorization: Bearer cn_..." \
-H "Content-Type: application/json" \
-d '{"is_hidden": true}' \
https://alaskanews.com/api/v1/articles/uuid
Returns 200 with the updated article. 404 when the article does not exist;
422 for invalid JSON or any refused value; 403 for a read-only key.
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"
}
| Field | Type | Default | Description |
|---|---|---|---|
| scope | string | community | community (pins within the article's community) or home (site-wide top story) |
| reason | string | null | Optional audit note |
Authorization (identical to the web UI Pin control):
scope=community→ caller must beadmin,editor, orplatform_adminon the article's community. A community-scoped API key may only pin articles inside its allowed communities.scope=home→ caller must beplatform_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:
| Param | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip first N results (default: 0) |
| sort | string | No | Sort mode: hot (default), new, nearby, top, rising, controversial, foryou |
| t | string | No | Time window for top sort: today, week (default), month, all |
| lat | number | No | Latitude for nearby sort |
| lng | number | No | Longitude for nearby sort |
| r | number | No | Radius 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.
| Param | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip first N results (default: 0) |
| sort | string | No | Sort mode: hot (default), new, nearby, top, rising, controversial, foryou |
| t | string | No | Time window for top sort: today, week (default), month, all |
| lat | number | No | Latitude for nearby sort |
| lng | number | No | Longitude for nearby sort |
| r | number | No | Radius 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.
| Param | Type | Required | Description |
|---|---|---|---|
| q | string | Yes | Search query |
| type | string | No | Filter: "all", "articles", or "communities" (default: "all") |
| limit | number | No | Max results (default: 20, max: 100) |
curl -H "Authorization: Bearer cn_..." \
"https://alaskanews.com/api/v1/search?q=budget&type=articles"
An empty or blank q is refused with 422 (code: empty_query) rather than answered, because a
search result is used as evidence and a query lost to a bug must not come back looking like matches.
For the newest articles without a keyword, the refusal's first next_steps entry is the request to
make: GET /api/v1/articles?community=<slug>&sort=new.
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.
Article documents
The filings, records and files a story rests on, listed under it (2026-10-16). A document is readable on its own page (/c/{community}/documents/{id}) only when it is marked public or indexed and a published article carries it.
GET /api/v1/articles/:id/documents
Public for a published article: the documents a reader is shown, each with href (its page when readable, else its original). Someone who can edit the article gets every attached document with its visibility and research_enabled.
POST /api/v1/articles/:id/documents
Upload a file to the article (multipart: file, title, published_at, optional document_type, agency, url), processed as any submitted document and attached; or attach a document the community already holds with JSON { "external_document_id": "..." }. Anyone who can edit the article uploads; attaching a held document is for an editor or admin of the community, or for the person who uploaded that document (2026-10-22). Anyone else gets 404, as for a document that does not exist. 201 when created or newly attached; 200 with result: duplicate when the bytes are already held.
PATCH /api/v1/articles/:id/documents/:documentId
{ "featured": true } makes it the reporting file (one per article); { "display_order": 2 } moves it.
DELETE /api/v1/articles/:id/documents/:documentId
Remove it from the article. The document stays.
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:
| Parameter | Type | Description |
|---|---|---|
q | string | substring match on tag name |
category | string | topic, location, organization or election |
A tag row carries no embedding: the 768-number vector and its source text were sent on every tag until 2026-10-15 and are no longer selected.
curl https://alaskanews.com/api/v1/tags?q=fisheries
GET /api/v1/tags/:slug
One tag with its children and parent. Public. The slug may be the tag's current
slug, a slug it had before a rename or a merge (previous_slugs), or one of its
aliases in any case or as a slug; the answer always carries the current
slug, so a caller can tell it asked by an old name. An alias two tags share
resolves to neither.
POST /api/v1/tags
Create a tag. Requires content_creator or above in the tag's community (a
platform admin for a platform-wide tag). Idempotent on the slug derived from
name within a category: an existing tag of the same category is returned
rather than a 409. When a tag of another category holds that slug, the new tag
takes the next free numbered slug (kodiak-island-borough-2), so a borough and
its government can share a name. Unknown fields are refused.
| Field | Type | Notes |
|---|---|---|
name | string | required |
category | string | required: topic, location, organization or election |
description | string | required, and must meet G1 (below) |
place_id | string | a Google place id from GET /api/v1/places; sent alone, the label and pin are filled from it |
location, latitude, longitude | string, number, number | send with place_id (null where none) to set a label and pin that differ from the place's, as for a statewide body |
parent_id | uuid | the parent tag |
aliases | string[] | each must name exactly this tag (G2, below) |
website_url, color, image_url, image_prompt, embedding_hint, community_id | optional |
What a new tag must carry, so a refusal names the field (422):
- A description (G1): two or three timeless sentences, 150 to 300 characters, the first 160 or fewer. No year, no dash, no dated word such as "currently", and nothing about this publication.
- A place, for a location or an organization: a
place_idalone, or all four location fields. A location under Outside Alaska needs none. - Aliases that name only this tag (G2): not a generic word such as "House" or "Representative", not this tag's own name, and not another tag's name or alias in the same category.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name":"Whittier","category":"location","place_id":"ChIJ...","description":"..."}' \
https://alaskanews.com/api/v1/tags
PATCH /api/v1/tags/:slug
Edit a tag. The same permission as create; every write is audited. Fields:
name, description, color, sort_order, location, latitude,
longitude, place_id, image_url, image_prompt, website_url,
parent_id, embedding_hint, about, about_set_by, exclude_from_cosine, geographic_type, image_credit, image_source, image_license. Unknown fields are refused with the allowed
list. PUT takes the same body and is platform admin only.
- A rename moves the slug; the old one is kept in
previous_slugs, so its pages and API lookups redirect. A slug a tag of the same category holds is refused (tag_slug_taken): merge the two instead. One a tag of another category holds gives this tag the next free numbered slug, as on create. - A
parent_idmoves the tag with its whole subtree in one transaction; a parent inside its own subtree is refused (tag_parent_cycle). - A place is written whole, as on create: a
place_idalone, or all four fields. A partial set is refused (tag_location_partial), and a location or organization cannot have its place cleared. - A description that changes must meet G1. It can be rewritten, never
removed. A description and its
embedding_hintcan go in one request, and the tag is re-embedded within minutes. aboutis the hub page's longer text: two or three paragraphs, 400 to 1,400 characters, with the description's content rules (no dates, dashes or quotation marks; about the subject alone).nullclears it.about_set_byisllmorapi(the default). Text an editor wrote in the tag editor is never replaced through the API (tag_about_editor_written).exclude_from_cosine: truekeeps the tag out of the candidates the tagging model is shown, and a location tag out of tagging by mention, while it stays on everything that already carries it.geographic_type, for a location tag only:place(a community or a feature, pinned on the map, the default),region(a grouping of places: a region, a borough or a census area) ormeta(not a place on an Alaska map, such as Outside Alaska). Only places are pinned on the map, offered in the place picker or counted in the all-locations feed.- A changed
image_urlclears the image's credit unless the same request sendsimage_credit,image_sourceandimage_license(aphoto_licensetoken such ascc_by_2). They can also correct the credit of the image a tag has.PUT .../imagesets an image and its credit from its source.
DELETE /api/v1/tags/:slug
Delete a tag. Platform admin only. Its children move to its parent. Refused,
changing nothing (tag_in_use), while a row that cannot lose it points at it,
such as a photo whose location it is: merge it instead.
POST /api/v1/tags/:slug/merge
Fold the tag at :slug into the tag named by into, of the same category.
Platform admin only. Body: { "into": "<slug>", "commit": false }.
Every reference moves to into: article, source, photo, event and follow links,
discovery and social configuration, photo and event columns, and the slug lists
stored in feeds, stations, photo hints and reader preferences, with the row
into already holds winning wherever both had one. The merged tag's children
move under into, its name and aliases become aliases of into, its slugs
become previous slugs of into so its pages redirect, and it is deleted.
Dry unless commit is true: a dry run makes the same writes and rolls them
back, so its per-table { moved, collisions } counts are a commit's. It also
lists alias_problems, names the merge would carry that G2 refuses; a commit
is refused until that list is empty. The dry run's next step is the commit.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"into":"wildfires"}' https://alaskanews.com/api/v1/tags/wildfire/merge
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:
| Parameter | Type | Description |
|---|---|---|
sort | string | ordering |
t | string | time window |
exclude | string | comma-separated article ids left out of every page (at most 20); a hub page passes its lead |
Each row carries tags (where the story is filed, for a card's chips) and
follows ({ title, href } of the earlier story a follow-up continues, or
null). pagination.total is the tagged set's size less the number of
exclude ids.
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.
PUT /api/v1/tags/:slug/image
Set a tag's image with its credit. Platform admin only. Four bodies:
multipart/form-datawith the image infile(JPEG, PNG, WebP or HEIC, up to 5 MB): an official logo, or a photograph with no credit to carry.- JSON
{ "library": "commons" | "dvids" | "nasa", "id": "..." }: a photograph from a public library, by Commons' file title, DVIDS'image:Nor NASA'snasa_id. It is read again from the library, so its licence is the library's and never the caller's, and refused (unusable_library_photo) when that licence is not one this site publishes. Its credit, page and licence are stored with it. - JSON
{ "community_photo_id": "<uuid>" }: a public, approved photo from the community pool, linked rather than copied, as an article cover links one. - JSON
{ "generate": true, "prompt"?: "..." }: an illustration generated with Leonardo, for a topic only; a location or organization takes a real photograph or an official logo, and is refused (422). Billed per call. It carries no credit and keeps its prompt inimage_prompt;promptreplaces the default.
A stored file goes to community-photos/tags/<category>/<slug>-<id>-<hash>.<ext>,
named by its contents. Every other body clears image_prompt; every body
replaces the credit, and the file the old image occupied is removed. Answers
{ slug, image_url }.
curl -X PUT -H "Authorization: Bearer $KEY" -F [email protected] \
https://alaskanews.com/api/v1/tags/city-of-wasilla/image
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"library":"commons","id":"File:Whittier harbor.jpg"}' \
https://alaskanews.com/api/v1/tags/whittier/image
Places
A place by name, then its coordinates: the lookup for creating or pinning a tag without typing coordinates. An API key or a signed-in session; the read rate limit, per user.
GET /api/v1/places?q=<text>
Up to five candidates, each with its label (description) and place_id: up
to two places already geocoded, then Google's suggestions. mode=town asks
Google for towns only. Text under two characters answers an empty list.
GET /api/v1/places/:placeId
One place's label (formatted_address), latitude, longitude, place_id,
and the city, state and level read from its address. From the geocoding cache
when the place is there, otherwise from Google, then cached. An id that is not
a Google place id is refused before any lookup.
curl -H "Authorization: Bearer $KEY" "https://alaskanews.com/api/v1/places?q=Whittier"
curl -H "Authorization: Bearer $KEY" https://alaskanews.com/api/v1/places/ChIJ...
People
GET /api/v1/persons
Bounded list of people the newsroom tracks.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
q | string | substring match on name |
community | string | community slug, resolved to an id server-side |
role | string | filter 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.
POST /api/v1/persons/:id/verify, /reject, /merge
Review a pending person. Requires editor or admin in the person's community (or a platform admin); each action is recorded in the registry audit log with the API key that made it.
verify: no body. Confirms the person and gives them a URL slug. To correct a name first,PATCH /api/v1/persons/:id, then verify.reject: no body. Marks them rejected and detaches their statements; the response carriesaffected_statements.merge: body{ "target_person_id": "<uuid>" }. Merges:idINTO the target, whatever either's status. The target keeps its name;:id's name becomes an alias and everything attached to:idmoves to the target.
POST /api/v1/persons/:id/slug
Sets the person's URL slug from their current name, for a person renamed after
being verified or one with no slug. The old slug is kept and
/persons/<old-slug> redirects to the new one. Editor or admin; no body.
GET, POST /api/v1/persons/:id/review-suggestions
Review notes on a pending person, shown on the pending-people page. GET returns
the notes a review left and the matcher's refusals of the person's transcript
labels. POST leaves a note: { "action": "merge" | "verify" | "reject" | "hold", "target_person_id"?, "reason", "evidence_url"? }, where only a merge
names a target; a note for the same action and target replaces the one before.
A note decides nothing. Editor or admin of the person's community.
DELETE /api/v1/persons/:id/review-suggestions/:suggestionId dismisses one
note; a matcher refusal is not a note and stays on its label.
PATCH /api/v1/speaker-labels/:id
Corrects a wrong speaker match. Body { "person_id": "<uuid>" | null }; null
keeps the label's name on the transcript only. The previous person's speaker
link and statements in that source move with the label when it was their only
label there, and the response's speaker_link says what happened. Editor or
admin of the source's community.
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.
404 to an anonymous caller for a person without a public page (pending,
rejected, or verified with no article, transcript or clip yet), the same answer
as for an id that does not exist. Since 2026-10-09; it answered 200 with []
for any id until then. An authenticated caller is answered as before, as
GET /api/v1/persons/:id already answers them for any person.
Calendar
GET /api/v1/calendar
Article events for calendar display. Public since 2026-11-04 (approved events only); a key scoped to newsrooms still reads only those.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
community | string | community slug |
type / types | string | one event type, or several |
categories | string | filter by category |
start / end | ISO date | date range |
tag | string | a tag's slug: only events carrying that tag itself, not its descendants; an unknown slug answers 404 |
limit / offset | integer | pagination |
count is the page's length and total every event the filters match, across
all pages. 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, { "article_id": "..." }. An editor or admin of the community, or anyone who can edit that article.
DELETE /api/v1/events/:id/articles/:articleId
Remove the event from that article. An event left with no link at all is deleted, and the response says event_deleted: true.
PATCH /api/v1/events/:id
Change any of the event's fields (title, dates, place, type, description, links, audience, performers and the rest). An editor or admin, or anyone who can edit an article the event comes from: its own link, a document attached to the article, or one of the article's sources.
DELETE /api/v1/events/:id
Delete the event. An editor or admin, or someone who can edit every article the event comes from.
POST /api/v1/events/merge
Fold one event into another, { "keep_id", "drop_id", "kind", "reason", "commit"? }. kind is required and names the relationship: duplicate is the same event, part is a part of keep_id (an agenda item, a conference session, a tournament game). The API reads no event's type or title, so what may be folded is the caller's policy.
Every link of drop_id (articles, documents, content sources), its tags and its community photos move to keep_id, keeping keep_id's own row where the two collide; drop_id's recording (media_url, capture_scheduled, capture_source_id) moves too when keep_id has none. drop_id is then rejected, not deleted, so it is still recognised as a copy, and merged_into sends any later link and its old URL to keep_id.
keep_id ends up richer, and a value it already has is never overwritten. A location moves as one set, when keep_id has no venue or drop_id names keep_id's venue at least as precisely. A duplicate also fills a blank description, summary, link, end, cover image, performers, door time and price, and gives a start time to a keep_id with none. A part's own versions of those describe the part, so they stay off keep_id, and it becomes an item of keep_id's schedule, carrying its summary, link, place and time. A dropped event's own schedule comes with it. Everything else stays on drop_id, which merged_into finds.
The caller must be able to manage both events, as for /reject: an editor or admin of the community, a platform admin, or someone who can edit a story each event belongs to. Dry unless commit: true, and the dry run reports what a commit would move and fill. A commit returns the merge_id that POST /api/v1/events/unmerge takes. 403 when the caller cannot manage either event, dry run included; 404 when either event does not exist; 422 when they are the same, kind or the reason is missing, they belong to different communities, drop_id is already merged, or keep_id is itself rejected. The two may be on different days, and keep_id's date stands: drop_id fills blanks only, and its start, end and door time only when both are on the same local day.
POST /api/v1/events/unmerge
Undo one merge, { "merge_id", "reason", "commit"? }. It puts back what it can show is unchanged (links, tags, photos, filled fields, the recording, and drop_id's status and reason) and reports the rest, so it is not exact: a link that reached keep_id after the merge stays there, a field edited since stays as edited, and a recording job the merge cancelled needs a person to reschedule it. The caller must be able to manage the kept event. Dry unless commit: true. 403 when the caller cannot, dry run included; 404 for an unknown merge; 422 for a blank reason, a merge already undone, a deleted event, or a kept event since merged away (undo that merge first).
POST /api/v1/articles/merge
Merge a duplicate article into the one that stays, { "keep_id", "drop_id", "reason", "insertions"?, "commit"? }. drop_id is hidden with merged_into set; it keeps its slug and its page answers with a 308 to keep_id. Before that, insertions may add facts only the duplicate carried, each { "op": "append" | "insert_after", "node", "text", "source_id", "source_quote" }, where node indexes keep_id's top-level body nodes. Nothing already in the kept article changes; each source_quote must appear in the article source_id names; together they may add at most 20% to its words. The fold is a machine edit: a revision is written and the editor of record stays as it was. Editor or admin of the newsroom, or a platform admin. Dry unless commit: true; a commit returns the merge_id the undo takes. 403 for any other caller; 404 for a missing id; 422 for the same id twice, two newsrooms, a blank reason, a drop already merged, a keep or drop that is not a public story, or a fold that does not pass (each problem listed). Plan docs/plans/2026-11-13-merge-duplicate-articles.md.
POST /api/v1/articles/unmerge
Undo one article merge, { "merge_id", "reason", "commit"? }. The merged article is public at its own URL again, anything the merge repointed goes back, and the kept article's text returns to before the fold when nobody has edited it since; otherwise the text is left and text_note says why. Same callers as the merge. Dry unless commit: true. 404 for an unknown merge; 422 for a blank reason, a merge already undone, a deleted article, or articles changed by a later merge (undo that one first).
Event schedules
An event can carry an ordered schedule: agenda items, conference sessions, a festival's lineup, a tournament's games. It knows nothing about the kind of event, and an article links to the event, never to an item. An item is { "id", "title", "summary"?, "url"?, "starts_at"?, "ends_at"?, "time_precision"?, "location"?, "merged_event_id"? }. time_precision is exact, not_before or approximate, and times are ISO 8601 with a zone; a time with no zone or a bare date gets a 422. GET /api/v1/events/:id returns the list as event_schedule.
Writes take the same rights as editing the event (canManageEvent), and mark the list as an editor's, after which nothing automatic changes it. A merge still adds to it.
GET /api/v1/events/:id/schedule:{ items, count }. Public for an approved event; otherwise the event's own visibility.POST /api/v1/events/:id/schedule:{ "items": [...] }adds items, skipping one already there (same title, and the same start or no start on either). Returns{ added, skipped, blocked }and a 201.PUT /api/v1/events/:id/schedule:{ "items": [...] }replaces the list, which is also how it is reordered. An item sent back keeps itsidand its merge link; a new one is given anid.PATCH /api/v1/events/:id/schedule/:itemId: changes the fields named, and a field sent as null is cleared. 404 when the item is not on this event.DELETE /api/v1/events/:id/schedule/:itemId: removes one item.
GET /api/v1/articles/:id/events
Every event a story reaches, for whoever can edit the article: linked to the article, to a document attached to it, to one of its sources, or to a document one of its sources was built from (not the context documents gathered around it). Each row carries badges (how the story reaches it), submission_status, other_links (how many links lie outside the story) and can_delete.
POST /api/v1/articles/:id/events
Add an event to the story, { "event_title", "event_date", "event_type", "event_end_date"?, "event_location"?, "description"?, "is_all_day"? }. It waits as pending_review until the article is published, then joins the calendar.
Media
GET /api/v1/stock-photos
Server-side proxy to Pexels and Unsplash, so their API keys stay on the server,
and to Wikimedia Commons (source=commons, no key), which holds far more
photographs of Alaska places. Returns simplified photo objects; a Commons photo
also carries license (a photo_license token), licenseName, categories,
personalityRights (Commons files it under "Personality rights warning":
identifiable people, whose consent a use may need), takenYear (the year Commons
states it was taken, or null) and coordinates when the file has them, and only public domain, CC0, CC BY 2.0, CC BY 4.0 and "No restrictions"
photos are returned.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
query | string | search terms |
source | string | pexels (default), unsplash or commons |
per_page | integer | results per page |
page | integer | page 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:
| Param | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip first N results (default: 0) |
| sort | string | No | Sort mode: hot (default), new, top |
| t | string | No | Time 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:
| Param | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip first N results (default: 0) |
| sort | string | No | Sort mode: hot (default), new, top |
| t | string | No | Time 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:
| Param | Type | Required | Description |
|---|---|---|---|
| type | string | No | Filter by source type: video, audio, email, web_scrape, rss, pdf, manual, media_upload, video_download, livestream, transcript_import, podcast, draft, concept |
| status | string | No | Filter by status: pending, scheduled, downloading, transcribing, classification, tagging, distillation, analyzing, face_clustering, concepts, drafting, completed, failed, skipped, processing, classifying, classified, drafted |
| limit | number | No | Max results (default: 20, max: 100) |
| cursor | string | No | The 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) |
| offset | number | No | Skip 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
| source_type | string | Yes | One of: video, audio, email, web_scrape, rss, pdf, manual |
| title | string | No | Source title or headline |
| source_url | string | No | URL of the original content |
| body_text | string | No | Plain text content |
| body_html | string | No | HTML content (for emails or web scrapes) |
| author_name | string | No | Original 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), drafted_articles array (articles generated from this source), pipeline_runs (the 20 most recent), recordings and meeting_parts.
A source is one meeting, and recordings lists every video of it the source holds (2026-10-13): role is transcribed for the one its transcript is made from, alternate for a copy it already covers, superseded for a recording a fuller one replaced (a short live capture after the channel posted its archive). Each carries platform, video_id, url, title (the publisher's), duration_seconds, started_at, live, and meeting_source_id when this source is itself a part of another source's meeting. meeting_parts lists the sources that are parts of this one's meeting (a Part 2, the next piece of a stream that restarted). One YouTube or Vimeo video is one source per community: creating a source for a video the community already holds is refused with 409 and the holding source's id.
PATCH /api/v1/communities/:slug/sources/:id
Edit a source: the API's counterpart of the source edit form, with the same fields and the same rule (editor or admin in the community). Send only the fields to change: title, description, body_text (the Content), captured_at, scheduled_start_at, location, latitude, longitude, place_id, city, state, priority, is_hidden, hidden_reason, author_name, author_org. Any other field is refused by name. Hiding a meeting (is_hidden: true, with an optional hidden_reason) records who hid it and when, and removes its transcript, clips, quotes and share images from every public surface (2026-10-12); unhiding clears the record. A hidden_reason without is_hidden: true is a 422.
curl -X PATCH -H "Authorization: Bearer cn_..." -H "Content-Type: application/json" \
-d '{"body_text": "Corrected Content."}' \
https://alaskanews.com/api/v1/communities/municipality-of-anchorage/sources/uuid
Response: the updated source. Content written with an API key marks the source as machine-written (raw_metadata.submitted_via = "api_key"), so the citation check applies to it from then on. Errors: 403 without editor or admin role, 404 for a source outside the named community, 422 for an unknown field, a date without a time zone, or text over the create route's limits.
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 own profile, including memberships with roles and community names. These are the person's private fields too (their news location and coordinates); a public profile shows none of them.
curl -H "Authorization: Bearer cn_..." \
https://alaskanews.com/api/v1/me
Response includes: id, slug, display_name, bio, avatar_url, website_url, social_links, is_public, created_at, location, latitude, longitude, place_id (the news location, private), location_town (the optional public town), news_radius_miles, timezone (a zone the reader chose for every time the site shows them, or null for their browser's; PATCH accepts an IANA zone or null), and memberships array (each with role, reputation_score, is_muted, and nested communities object).
PUT /api/v1/me
Update the authenticated user's profile: a partial update through the same service and rules as the profile form (Users.updateProfile, 2026-10-10).
Request Body (all fields optional):
{
"slug": "jane-reporter",
"display_name": "Jane Reporter",
"bio": "Local journalist covering city council",
"location": "Anchorage, AK",
"latitude": 61.2181,
"longitude": -149.9003,
"place_id": "ChIJ...",
"location_town_place_id": "ChIJ...",
"news_radius_miles": 50,
"timezone": null,
"website_url": "https://example.com",
"social_links": [{ "platform": "bluesky", "url": "jane.bsky.social" }],
"is_public": true
}
- An unknown field is refused with 422 rather than ignored.
news_radius_milesmust be one of 10, 25, 50, 100, 250 or 500.slugfollows the username rules (2 to 30 characters, lowercase letters, digits and hyphens, not starting or ending with a hyphen); a reserved name or one taken by someone else is refused.display_nameandslugmay not read as a variation of "Alaska News".- The public town is set by
location_town_place_id(a Google place id for a town); the server builds the "City, State" label.location_townmay be sent only to repeat the current town or, asnull, to clear it.
At least one field must be provided.
GET/PUT /api/v1/me/feed-preferences
The remembered filters for each page, and the /stories playback speed.
PUT saves one page's filters: routePattern (the page's route pattern, such as / or /topics/[slug]) plus any of sort, t, mine, hide_external, community, topic. null, "" or false clears a key, and a page left with no keys is removed. The response is the whole saved object. A body without routePattern is refused with 422; until 2026-10-10 it replaced the flat keys, which no client used and which dropped the audio speed. The change is one database call under a row lock (merge_own_feed_preferences), so two saves at once cannot undo each other.
{ "routePattern": "/topics/[slug]", "sort": "new", "mine": true }
User Bookmarks
GET /api/v1/me/bookmarks
List the authenticated user's bookmarked articles with community info.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
| limit | number | No | Max results (default: 20, max: 100) |
| offset | number | No | Skip 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Descriptive name for the key |
| community_ids | string[] | No | Scope to specific communities (default: all) |
| expires_at | string | No | ISO 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:
| Param | Type | Default | Description |
|---|---|---|---|
| limit | number | 20 | Results per page (max: 100) |
| offset | number | 0 | Number 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_idfor 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. Audio only: frames go to POST /api/v1/worker/jobs/:id/frames below (corrected 2026-10-04: this section documented frame, frame_hash and frame_description fields, which no worker had sent since 2026-04-16 and which were removed that day).
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" \
https://alaskanews.com/api/v1/worker/jobs/uuid/chunks
| Field | Type | Required | Description |
|---|---|---|---|
| chunk | file | Yes | MP3 audio file (max 10MB) |
| chunk_index | number | Yes | Zero-based chunk index |
| start_offset_seconds | number | Yes | Start time in seconds |
| end_offset_seconds | number | Yes | End time in seconds |
POST /api/v1/worker/jobs/:id/frames
Upload a batch of a job's video frames (up to 30), each with its own JPEG. The worker must be approved and hold the job.
curl -X POST -H "Authorization: Bearer cn_..." \
-F 'frames_meta=[{"frame_index":1,"chunk_index":0,"timestamp_seconds":0,"description":"..."}]' \
-F "frame_0=@frame_0001.jpg" \
https://alaskanews.com/api/v1/worker/jobs/uuid/frames
| Field | Type | Required | Description |
|---|---|---|---|
| frames_meta | JSON array | Yes | One entry per frame: frame_index, chunk_index and timestamp_seconds (numbers, required); ocr_text (string, optional: on-screen text the worker read, "" for none) and description (string, optional: sent only by a worker describing frames locally; otherwise the frame_description pipeline stage writes it). A column is written only when the batch carries it, so re-uploading a frame never erases its description. |
| frame_N | file | Yes, per entry | The JPEG for entry N (max 2MB) |
The whole batch is validated before anything is written: a malformed batch answers 422 with issues and stores nothing. A storage or database error answers 500, and a retry is safe because frames are stored idempotently on (job_id, frame_index). A 201 means every frame was stored: frames_stored equals the batch length.
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:
| Param | Type | Description |
|---|---|---|
| status | string | Filter by status: pending, approved, suspended |
| trust_level | string | Filter by trust level: new, trusted, verified |
| limit | number | Max results (default: 20) |
| offset | number | Skip 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:
| Param | Type | Description |
|---|---|---|
| status | string | Filter by status: pending, processing, completed, failed, cancelled |
| platform | string | Filter by platform: youtube, vimeo, facebook, etc. |
| job_type | string | Filter by type: vod, livestream |
| worker_id | string | Filter by assigned worker |
| limit | number | Max results (default: 20) |
| offset | number | Skip 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.