API
Public JSON API spanning every section of black.wiki — apps, news, directory, playbooks, homage, sayings, polls, jobs, the yard, and more. Read is open; writes need a token.
Quick start
All endpoints live under https://black.wiki/api. Responses are JSON, CORS is open, and read endpoints are cached for ~60s.
curl https://black.wiki/api/apps curl https://black.wiki/api/news curl https://black.wiki/api/sayings/random
Authentication
Read endpoints are open. Writes (anything marked AUTH on its row) need a token sent as a bearer header. Two flavors are accepted:
- Supabase JWTs — for mobile apps and any client using the Supabase SDK to sign the user in. Pass the session's
access_tokenasBearer <token>. The server enforces RLS as that user. To bootstrap from a signed-in browser, hit/api/me/token(cookie session required) and feed the returned access_token + refresh_token tosupabase.auth.setSession(...)on the device. - Personal tokens (bwk_...) — for browser extensions, CLIs, and one-off scripts. Generate at /account/tokens. Same Bearer header shape.
1 · Generate a token
- Sign in to black.wiki, then go to /account/tokens.
- Click New token, give it a label so you can recognize it later (e.g. "chrome-extension" or "ci-bot").
- Copy the token immediately. It starts with
bwk_and is shown exactly once — closing the dialog without copying means you have to regenerate.
2 · Send it with each request
Add an Authorization header with the value Bearer <your-token>.
# cURL
curl https://black.wiki/api/me \
-H "Authorization: Bearer bwk_xxxxxxxxxxxxxxxxxxxxxxxx"
# JavaScript (fetch)
const res = await fetch("https://black.wiki/api/me", {
headers: { Authorization: `Bearer ${token}` },
});
# Python (requests)
import requests
r = requests.get(
"https://black.wiki/api/me",
headers={"Authorization": f"Bearer {token}"},
)3 · Submit a write (example)
POST endpoints take a JSON body. Always set Content-Type: application/json alongside the bearer header.
curl -X POST https://black.wiki/api/sayings/submit \
-H "Authorization: Bearer bwk_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"text": "If it cost you your peace, it was too expensive.",
"meaning": "Stop chasing things that drain you.",
"region": "South"
}'Error responses
401 Unauthorized— header is missing, malformed, or the token has been revoked. Regenerate at /account/tokens.403 Forbidden— the token is valid but the caller isn't allowed to do that (e.g. the endpoint is admin-only).402 Payment Required— the caller doesn't have enough points for a points-gated action (e.g. claiming a community-mod slot or submitting to The Yard).429 Too Many Requests— you've hit the rate limit on a write endpoint. Wait and retry; the limit is per-user per-hour.
Best practices
- Never put a token in client-side JS that ships to browsers. Tokens grant write access on your account; use them from a server or a sandboxed extension only.
- Store tokens in environment variables, not source files. Treat them like a password.
- Use one token per integration so you can revoke them independently. Label them clearly on /account/tokens.
- Lose a token? Revoke it immediately and generate a new one. The old one stops working the instant you revoke.
Conventions
- Responses always return JSON. Errors come back as
{ "error": "..." }with an HTTP status code. - List endpoints share
limitandoffset. Default limits are noted per endpoint. - Slugs are lowercase, hyphenated, 2–80 characters.
- Auto-hidden / draft items don't appear in public lists.
- Submission endpoints rate-limit per user.
Endpoints by section
Click a section to expand. Looking for the old single-page docs? /docs/api/v1.
Apps
4 endpoints
Community-built tools and extensions listed in /directory/apps.
/api/appsList published apps that have the JSON API enabled.
Query
qFilter by name (case-insensitive).limitDefault 50, max 100.offsetPagination offset.
/api/apps/{slug}Single app with its public metadata.
/api/apps/{slug}/filesList the JSON data files the app owner has uploaded.
/api/apps/{slug}/data/{filename}Fetch a single data file by name.
News
2 endpoints
Published news posts including both editorial and aggregated articles.
/api/newsList published news posts, newest first.
Query
qFull-text search title + excerpt.sourceFilter by source_name.limitDefault 30, max 100.offsetPagination offset.
/api/news/{slug}Single news post with body HTML.
Directory
5 endpoints
People, businesses, landmarks, services, and events. Each kind has its own URL path. Businesses and landmarks have curated subcategory taxonomies — fetch them once from /api/directory/categories and use them with ?category= here. If you're building an agent that doesn't know in advance whether a thing is a business or a landmark (commerce vs cultural-historic), call /api/directory/search instead and it'll match across kinds. The legacy /directory/places endpoints still work — they 301 to either /landmarks or /businesses depending on the entry.
/api/directory/searchCross-kind search. Same tokenized + synonym-expanded text search as the per-kind endpoints, but matches across business, landmark, service, and event by default. Each row includes its kind + kind_slug so callers can route. Best choice for AI agents: one call, no need to guess the category up front.
Query
qFree-text search. Tokenized + synonym-expanded — "food" matches Restaurant places, "drinks" matches Bar / Lounge, etc.kindsComma-separated subset of business,landmark,service,event,person. Default: business,landmark,service,event. The legacy value 'place' is accepted for backwards compatibility and quietly expanded to business + landmark.zip5-digit US ZIP to filter by metro. Same heuristic as /api/directory/{category}.limitDefault 20, max 100.offsetPagination offset.
/api/directory/categoriesCurated subcategory taxonomies, keyed by kind: { businesses, landmarks, services, events, people }. Use these exact strings with ?category= on /api/directory/{category}. Cached one hour.
/api/directory/{category}List published entries in a category. Category is the plural slug: businesses, people, places, services, events. Response includes available_categories so clients can render filter chips without a separate lookup, and filtered_by_zip when ?zip= is set.
Query
qFree-text search. Tokenized on whitespace; each word must match name, summary, category, city, or region (ilike). So ?q=Tampa+restaurant returns places in Tampa with category Restaurant — you do NOT need the entry name to contain both words.letterFilter by first letter (a-z or #).categoryFilter by curated subcategory. Repeat or comma-separate for OR (e.g. ?category=Restaurant,Café). Unknown values are dropped.zip5-digit US ZIP. Resolves to the closest big-city metro (within 120 km) and filters by that city OR same-state. Falls back gracefully if the ZIP can't be resolved.limitDefault 24, max 100.offsetPagination offset.
/api/directory/peopleShortcut for the people category.
/api/directory/jobsOpen + filled job listings.
Query
kindFilter by job kind.remotetrue to filter remote-only.
Playbooks (Level Up)
2 endpoints
Community guides and game plans.
/api/playbooksList published playbooks.
Query
topicFilter by topic slug.qFilter by title.limitDefault 30, max 50.offsetPagination offset.
/api/playbooks/{slug}Single playbook with body HTML and quiz JSON.
Homage
3 endpoints
Tributes to people, films, music, brands, and other contributions worth honoring.
/api/homageList published homage entries.
Query
qFilter by name.catFilter by category slug.letterFilter by first letter.limitDefault 24.offsetPagination offset.
/api/homage/{slug}Single homage entry.
/api/homage/categoriesCategory dictionary used by /api/homage cat filter.
Sayings
5 endpoints
Phrases, proverbs, and one-liners from Black culture.
/api/sayingsList published sayings.
Query
qFilter by text + meaning.categoryFilter by category slug.regionFilter by region tag.limitDefault 24.offsetPagination offset.
/api/sayings/{slug}Single saying with meaning + attribution.
/api/sayings/randomOne random saying. Good for widgets / extensions.
/api/sayings/categoriesCategory dictionary.
/api/sayings/submitAUTHSubmit a saying for moderator review. Goes into the queue at /admin/sayings/queue.
Body
textstringThe saying itself.meaningstring?Plain-English explanation.attributionstring?Optional credit (e.g. "Old-school Black uncle").regionstring?South / Midwest / etc.categorystring?Category slug.
Polls
3 endpoints
Daily community polls with question + options + tallies.
/api/pollsList open polls, newest first.
Query
statusopen | closed (default: open).limitDefault 30.offsetPagination offset.
/api/polls/{slug}Single poll with options + current vote tallies.
/api/pollsAUTHSubmit a community poll. Requires a signed-in user (Supabase session cookie or bearer). When the admin toggle polls_require_approval is on, the poll lands as 'draft' awaiting review; otherwise it publishes instantly. Response includes pending_review when held for moderation.
Body
questionstringPoll question. Max 280 chars.descriptionstringOptional context shown above the options.optionsstring[]2-8 option labels. Empty values are dropped.allow_commentsbooleanDefault true. Set false to disable per-vote comments.
The Yard (points shop)
4 endpoints
Catalog of items redeemable with points. Read-only; redemption is web-only.
/api/the-yardList active shop items.
Query
qFilter by title.limitDefault 24.offsetPagination offset.
/api/the-yard/{slug}Single shop item with description + point cost.
/api/yard/submitAUTHCurrent submission fee (sliding cost based on community size) and the caller's eligibility.
/api/yard/submitAUTHSubmit a community-donated Yard item. Goes to the admin review queue.
Body
titlestringItem title.descriptionstring?Optional rich description.image_urlstring?Optional cover image.point_costintCost in points to redeem.stockint?Null = unlimited.max_per_userint?Null = no per-user cap.fair_market_value_usdint?Approximate FMV (1099 reporting).
Community moderators
2 endpoints
Read the current slot config + roster; sign-in required to claim a slot.
/api/community-modsConfig (slots, term days, cost), currently held terms, and the viewer's eligibility.
/api/community-mods/claimAUTHClaim an open slot. Debits the configured point cost.
Leaderboard
1 endpoint
Public ranking of contributors by points earned.
/api/leaderboardTop contributors, points-descending. Excludes opt-out users.
Query
limitDefault 50, max 100.offsetPagination offset.
Me (signed-in)
3 endpoints
The caller's own data. All endpoints require a user token.
/api/meAUTHCurrent profile + role flags + points balance.
/api/me/pointsAUTHPoints event log for the caller.
Query
limitDefault 50.offsetPagination offset.
/api/me/tokenAUTHCookie-only. Returns the active Supabase session (access_token + refresh_token + expires_at) so a signed-in browser can hand the session to a mobile/native client. The client feeds the pair to supabase.auth.setSession(...) and then talks to the rest of /api with the access token as Authorization: Bearer.
Content reports
1 endpoint
Flag a piece of content for moderator review. Rate-limited and dedupe per (target, reporter).
/api/reportAUTHSubmit a report. Three open reports against a target auto-hide it pending review.
Body
target_typeenumdirectory_entry | app | job | playbook | homage_entry | news_posttarget_iduuidTarget row id.reason_categoryenumnot_black_owned | misleading | spam | duplicate | abusive | otherreason_detailstring?Free-text up to 1000 chars.
Geocoding
2 endpoints
Server-side proxies so clients don't have to hit third-party APIs directly. Both responses are cached aggressively (ZIPs are static; reverse-geocodes are cached one minute).
/api/geocode/zipResolve a 5-digit US ZIP to lat/lng + city + state, then snap to the closest big-city metro (within 120 km). Same metro the Near me button and the search page's ZIP filter use. Returns { zip, city, region, metro_city, metro_region, lat, lng }.
Query
zipRequired. 5-digit US ZIP.
/api/geocode/reverseReverse-geocode a (lat, lng) pair. By default snaps to the closest curated big city within 120 km (good for "near Tampa"-style header text). Pass precise=1 to skip the snap and return the locality the GPS coords actually land in (Apollo Beach, Brandon, etc.). Always returns the snapped metro alongside under metro.{city, region}. Response: { city, region, country, metro }.
Query
latRequired. -90 to 90.lngRequired. -180 to 180.preciseOptional. 1 = skip the 120 km metro snap and return Nominatim's literal locality (e.g. Apollo Beach). Default 0.
On Stage
2 endpoints
Curated spotlight features pointing to people, businesses, or works in the directory. Same ordering and payload the public /on-stage page uses.
/api/on-stageList published features in display order (sort_order asc, published_at desc).
Query
limitDefault 60, max 100.
/api/on-stage/{slug}Single feature by entry slug. Includes the caption, Threads permalinks, cover image, and the underlying directory entry.
Games
9 endpoints
Mobile-friendly game endpoints. The index lists what's available; per-game GETs return the current daily state and POSTs record the user's play. Plays require a user token; reading the daily question/pairs is open.
/api/gamesStatic index of available games with the endpoints that drive each one.
/api/games/triviaToday's trivia question + (for auth'd callers) whether they've already played and the result.
/api/games/triviaAUTHRecord the caller's play for today. Returns 409 if they've already played.
Body
question_iduuidFrom the GET response.correctbooleanWhether the answer was right.
/api/games/matchToday's 4 Match pairs + (for auth'd callers) whether they already played, won/lost, and attempts.
/api/games/matchAUTHRecord today's Match play. Returns 409 if already played.
Body
wonbooleanDid they solve all pairs?attemptsint?Number of guess attempts.
/api/games/wordleToday's 5-letter answer (rotates daily by UTC date) + the auth'd caller's play state. 6 max guesses.
/api/games/wordleAUTHRecord today's Wordle play. Caller validates guesses locally; server only stores the final outcome. 409 if already played.
Body
wonbooleanDid they solve the word?attemptsint?Number of guesses used (1-6).
/api/games/guessToday's mystery item (drawn from published apps + directory entries), a staged clue list, and the auth'd caller's play state. 4 max guesses.
/api/games/guessAUTHRecord today's Guess the Listing play. 409 if already played.
Body
wonbooleanDid they guess the item?attemptsint?Number of guesses used (1-4).
Nearby (GPS discovery)
1 endpoint
Unified "what's around me" feed across the directory + On Stage. Every published entry is geocoded; results are sorted by true haversine distance from the caller's coords. Each item carries distance_km. If the caller is in a region with no geocoded entries (mid-ocean, far rural), the response falls back to the legacy metro-snap filter so it never returns empty. resolved_metro is always the nearest curated big city for header text.
/api/nearbyReturns { resolved_city, resolved_region, resolved_metro, radius_km, cities, items }. items is a mixed list with a 'kind' discriminator: business, landmark, place, person, event, on_stage. Each item includes distance_km (rounded to 0.1 km) along with address, website, event dates, and lat/lng where the row has them.
Query
latRequired. -90 to 90.lngRequired. -180 to 180.radius_kmDefault 80, max 500.kindsComma-separated subset of business,landmark,place,person,event,on_stage. Default: all.limitDefault 30, max 100.
Timeline
1 endpoint
Year-ordered mosaic of every published directory entry that has a year_start. Same data /timeline renders on the web, plus the era bands so a mobile client can draw the same tinted backdrop without duplicating the era table.
/api/timelineReturns { items, count, eras, filters }. Each item has the entry's slug, name, kind, year_start/end, summary, and a page_url. Eras are the curated bands (Early Diaspora, Atlantic Slavery, Reconstruction, Jim Crow, Great Migration, Harlem Renaissance, Civil Rights, etc.).
Query
fromEarliest year_start (inclusive).toLatest year_start (inclusive).kindsComma-separated subset of business,person,landmark,place,event,service. Default: all.eraEra slug from the eras response — narrows from/to to that era's bounds.limitDefault 1000, max 2000.
Product reviews
1 endpoint
Flat feed of approved product reviews across the site. Reviews are also embedded in /api/products/{slug}; this endpoint is for a chronological "Reviews" tab.
/api/reviewsApproved reviews newest-first. Each item is denormalized with the product slug, name, and image so the client can render without a second fetch.
Query
limitDefault 30, max 100.offsetPagination offset.min_rating1–5. Filter to reviews at or above this rating.productProduct slug. Filter to one product.
Ask (natural-language search)
1 endpoint
One endpoint behind the floating chat widget. Pass a user question + optional coordinates and stream back a directory-grounded answer plus the matches it cited. Results come strictly from black.wiki — the model is constrained to never invent businesses. Same endpoint Persona One will call for voice/SMS.
/api/askReturns an NDJSON stream — one JSON object per line — so clients can render tokens as they arrive. Event types: 'meta' (matches + parsed intent + resolved viewer metro), 'delta' (a text fragment of the answer), 'done' (final answer + cited slugs), 'error' (rate limit / extraction failure). Non-streaming consumers can buffer the full body and read just the meta + done lines. Rate-limited to 12 req/min per IP.
Body
querystringUser's question. Max 500 chars.latnumber?Latitude. Used to bias matches to the viewer's metro.lngnumber?Longitude.
Fake content reports
2 endpoints
Community-watch intake for fake profiles, AI-generated content, impersonation, scams, and misinformation found OFF black.wiki (on social platforms etc.). Anyone can submit — signed-in or anonymous. The public feed returns only reports a moderator has confirmed, with all submitter info stripped.
/api/report-fakePublic awareness feed — confirmed reports only, submitter PII removed. Cached.
Query
categoryFilter to one of: fake_profile, ai_generated, impersonation, scam, misinformation, other.limitDefault 50, max 100.
/api/report-fakeFile a report. No account required (signed-in callers are auto-linked). Runs the same moderation + IP-block defenses as feedback.
Body
categorystringOne of: fake_profile, ai_generated, impersonation, scam, misinformation, other.descriptionstringWhat's wrong with it. 3–4000 chars.platformstring?Where it was seen (e.g. "Threads", "TikTok").content_urlstring?Link to the offending profile or content.handlestring?The account name or handle.submitter_namestring?Optional, for credit/follow-up.submitter_emailstring?Optional, never shown publicly.
Business shout-outs
2 endpoints
Public "Did you support a Black business today?" shout-outs left from the home page. Anyone can post — signed-in or anonymous. The feed returns approved shout-outs with submitter PII stripped (display name only). To link a shout-out to a real directory business, autocomplete with GET /api/directory/businesses?q= and pass the chosen entry's id as directory_entry_id. If you omit it and name a business that isn't listed yet, a signed-in post creates a draft directory listing (pending admin review).
/api/business-shoutoutsApproved shout-outs, newest first. business_slug is set when the linked business is published (build /directory/businesses/{business_slug}). Cached.
Query
limitDefault 12, max 100.offsetPagination offset.
/api/business-shoutoutsLeave a shout-out. No account required (signed-in callers are auto-linked, and a later signup from the same browser adopts anonymous shout-outs). Response includes signedIn so the client can nudge account creation.
Body
business_namestringThe business. 1–120 chars.ratingnumber1–5 stars.citystring?Where the business is.bodystring?A quick word. Max 1000 chars.submitter_namestring?Optional display name.directory_entry_idstring?UUID of a directory business (from /api/directory/businesses autocomplete) to link this shout-out to.
Media kit
1 endpoint
Everything a “support / spread the word” screen needs: the site QR code, downloadable brand assets, printable sheets (poster, stickers, storefront sign), and merch (print-on-demand) links. All URLs absolute.
/api/media-kitSite QR (png + svg), brand_assets, brand_colors, printables, and merch links. merch[].available is false until a store URL is configured.
Admin
1 endpoint
Staff-only endpoints. Require a moderator/admin session (cookie or Bearer token); non-staff get 403.
/api/admin/recent-submissionsADMINLatest user submissions across every area (flags, feedback, comments, reviews, directory entries, games, reports, shout-outs, and more), merged newest-first. Returns { items, sources, skipped }.
Query
limitDefault 80, max 200.sourceNarrow to one area key (see the sources array in the response).