Developer API
Every Fanvaiy publication has a free, open, read-only API. Pass your
siteId and pull
your published stories, categories and branding as JSON to power custom frontends, mobile apps, and content syndication. Render it however you like, in React,
Vue, Svelte, plain HTML, or anything that can fetch a URL. No API key, no SDK, no sign-up.
It serves public content, so there's nothing to authenticate. Call it from a browser or a server, with CORS enabled for any origin.
Only published, free stories and public site metadata are returned. Billing, drafts, paid articles and private fields are never exposed.
Every response carries Cache-Control and an ETag, so reads are served from cache and conditional requests return 304.
These calls run from your browser against the real API, with no key and no proxy in between. It starts on
blog, our own publication. Swap in your site id, your subdomain, or your custom domain to see your own content.
Press Send request to call the API from your browser.
Open your Fanvaiy dashboard, where the site id sits in your site settings. You can also use your subdomain or custom domain in its place.
The base URL is
https://fanvaiy.com/api/public/v1
The same call, in whichever stack you are working in.
curl "https://fanvaiy.com/api/public/v1/sites/blog/posts?limit=10"
All endpoints are GET, return JSON, and accept your site id, subdomain, or custom domain as {siteId}.
/api/public/v1/sites/{siteId}
Publication profile with branding, social handles, contact details, legal pages, SEO metadata and the full category list. The one call that bootstraps a frontend.
data.site
{
"data": {
"id": "b6eae995-e856-4…",
"name": "My Magazine",
"name_en": "My Magazine",
"slogan": "Stories worth your time",
"description": "An independent magazine",
"color": "#111111",
"logo": "https://…/logo.png",
"favicon": "https://…/favicon.png",
"theme": "news",
"lang": "en",
"domain": "mymag.com",
"subdomain": "mymag",
"url": "https://mymag.com",
"social": { "twitter": "mymag", "instagram": "mymag", "facebook": null },
"contact": { "email": "hello@mymag.com", "phone": null, "address": null },
"legal": { "terms": "https://…", "privacy": "https://…", "codeOfConduct": null },
"seo": {
"ogTitle": "My Magazine",
"ogDescription": "Stories worth your time",
"ogImage": "https://…/og.png",
"ogKeywords": "magazine, culture"
},
"categories": [
{
"id": "ab7ef326-2ef0-4…",
"name": "World",
"name_en": "World",
"slug": "world",
"description": null,
"image": null,
"categoryStyle": null,
"isPromoted": true,
"layoutWeight": 10,
"externalUrl": null,
"hideFromHome": false
}
]
}
}
/api/public/v1/sites/{siteId}/categories
Just the categories, ordered the way they appear on the site, heaviest layout weight first. Use isPromoted for the main navigation and hideFromHome to keep a category off the home page.
data[] of category
/api/public/v1/sites/{siteId}/posts
Published stories, newest first, without the article body. Paid and unpublished stories are never included.
data[] of post, plus meta for paging
Query parameters
category
string
Filter by category slug.
featured
boolean
Set to true for featured stories only, or false to exclude them.
search
string
Case insensitive match on title and summary.
page
integer
Page number, defaults to 1.
limit
integer
Per page, 1 to 50, defaults to 20.
/api/public/v1/sites/{siteId}/posts/{id}
A single story with the full HTML body, its category, its author, and the narration audio when one exists. Answers 404 when the story belongs to another publication.
data.post
Copy one of these into Claude Code, Cursor, v0, or whichever assistant you use, add your site id, and let it build. Each prompt carries the full API contract, so the assistant works from the real field names instead of inventing them.
Already building? Paste this into your assistant so it stops guessing field names.
Every prompt carries the whole API contract, so an assistant can build the frontend without guessing a single field name.
A full publication with home, category, story and search pages, static rendering and SEO.
Build a production ready news site with Next.js using the App Router, TypeScript and Tailwind. All content comes from the Fanvaiy public API described below.
Pages
- / leads with the newest featured story, then the latest stories in a grid, then one row per category loaded with limit 4.
- /category/[slug] lists a single category with numbered pagination driven by meta.totalPages.
- /story/[id] renders the story body, the cover image with its caption, the byline when an author is present, the published date and a narration player when audio is returned.
- /search reads the q query string and passes it to the posts endpoint as search.
- app/sitemap.ts and app/robots.ts, both generated from the API.
Data layer
- One typed client in lib/fanvaiy.ts exporting getSite, getCategories, getPosts and getPost, with types written from the shapes below and every optional field marked nullable.
- Fetch server side only, with next revalidate set to 300 so pages render statically and refresh in the background. No client component ever touches the API.
- Read the site key from process.env.NEXT_PUBLIC_FANVAIY_SITE and fail loudly at startup when it is missing.
Design and SEO
- Take the accent colour from the site color field and the wordmark from logo, so the site repaints itself when the publisher changes branding.
- generateMetadata on every page, seeded from the site seo block and overridden per story with its own title, summary and image. Add NewsArticle JSON-LD to story pages.
- Set the html lang attribute from the site lang field, and dir rtl when that value is dv.
Rules
- Call notFound() when the API answers 404.
- Render body with dangerouslySetInnerHTML inside a prose wrapper.
- Guard every optional field, especially author, image and tags.
Fanvaiy public API, read only, no authentication, no SDK, CORS open to every origin.
Base URL
https://fanvaiy.com/api/public/v1
Site key
Anywhere SITE appears below, use the publication's site id, its subdomain, or its custom domain.
Endpoints, all GET, all returning JSON
GET /sites/SITE
{ "data": site }
GET /sites/SITE/categories
{ "data": [category] }
GET /sites/SITE/posts?category=&featured=&search=&page=&limit=
{ "data": [post without body], "meta": { "page", "limit", "total", "totalPages" } }
category takes a category slug, featured takes true or false, search matches title and summary,
limit runs 1 to 50 and defaults to 20, page defaults to 1.
GET /sites/SITE/posts/POST_ID
{ "data": post with body }
Shapes
site = {
id, name, name_en, slogan, description, color, logo, favicon, theme, lang, domain, subdomain, url,
social: { twitter, instagram, facebook },
contact: { email, phone, address },
legal: { terms, privacy, codeOfConduct },
seo: { ogTitle, ogDescription, ogImage, ogKeywords },
categories: [category]
}
category = {
id, name, name_en, slug, description, image,
categoryStyle, isPromoted, layoutWeight, externalUrl, hideFromHome
}
post = {
id, title, latin, summary, image, imageCaption, videoUrl, isFeatured,
publishedAt (ISO 8601 string), likes, tags (array or null), url (canonical page on the publisher's own site),
body (HTML, single post responses only),
audio: { url, duration } (present only when the story has a narration),
category, author: { id, name, name_en, profilePicture }
}
Rules to code against
- The author key is absent, not null, when the publisher hides the byline, so guard for it everywhere.
- Paid, draft and unpublished stories are never returned, so everything you receive is safe to show.
- name_en and latin are Latin script companions of name and title, used by publications writing in Dhivehi. Fall back to name and title.
- body is publisher authored HTML and is meant to be rendered as HTML, not escaped.
- Posts come back newest first. There is no sort parameter.
- Any missing site or story answers 404 with a JSON body. Nothing else is thrown.
- image, videoUrl, imageCaption, tags, slogan, logo and most site fields can be null, so type them as nullable.
- Every response carries ETag and Cache-Control. The safety limit is 300 requests a minute per IP, so cache on your side and never call the API once per rendered card.
Read what the assistant writes before you ship it. These prompts describe the API accurately, but the code that comes back is still yours to review.
Posts behind a paywall are never returned by this API, neither in lists nor by direct id. Only free, published posts are public.
When a publisher hides the byline on a story, the author key is left out of the payload rather than set to null, so check for it before you read a name.
Successful responses carry max-age=60, s-maxage=300 and an ETag. Send it back as If-None-Match for a fast 304 when nothing changed.
A safety limit of 300 requests a minute per IP, which is generous for a cached frontend and tight for a scraper. Cache on your side and never call the API once per rendered card.
Stories always come back newest first, and there is no sort parameter. Page with page and stop when you reach meta.totalPages.
Publications writing in Dhivehi carry a Latin companion for their names, in name_en on sites and categories and latin on stories. Fall back to the main field when it is empty.
200
The request succeeded. Lists also carry a meta block.
304
Your If-None-Match matched, so nothing was sent and nothing changed.
404
Unknown site, unknown story, a story that belongs to another publication, or a publication that is no longer public. The body is JSON.
429
You crossed the per minute limit from one IP. Back off and cache.
Launch a Fanvaiy publication in minutes, then build any frontend you like on top of it.
Start a publication