Developer API

Build custom newsroom frontends on Fanvaiy

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.

Open & keyless

It serves public content, so there's nothing to authenticate. Call it from a browser or a server, with CORS enabled for any origin.

Read-only & safe

Only published, free stories and public site metadata are returned. Billing, drafts, paid articles and private fields are never exposed.

Fast & cached

Every response carries Cache-Control and an ETag, so reads are served from cache and conditional requests return 304.

Try it live

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.

GET
Response
Press Send request to call the API from your browser.

Quickstart

  1. 1

    Find your siteId

    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.

  2. 2

    Call the API

    The base URL is

    https://fanvaiy.com/api/public/v1
  3. 3

    Fetch and render

    The same call, in whichever stack you are working in.

    curl "https://fanvaiy.com/api/public/v1/sites/blog/posts?limit=10"

API reference

All endpoints are GET, return JSON, and accept your site id, subdomain, or custom domain as {siteId}.

GET /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.

Returns 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
      }
    ]
  }
}
GET /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.

Returns data[] of category
GET /api/public/v1/sites/{siteId}/posts

Published stories, newest first, without the article body. Paid and unpublished stories are never included.

Returns 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.
GET /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.

Returns data.post

Build a frontend with AI

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.

The API contract on its own

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.

Next.js news site

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.

Good to know

Paid posts are private

Posts behind a paywall are never returned by this API, neither in lists nor by direct id. Only free, published posts are public.

Hidden bylines

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.

Caching

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.

Rate limits

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.

Ordering and paging

Stories always come back newest first, and there is no sort parameter. Page with page and stop when you reach meta.totalPages.

Two scripts, two fields

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.

Status codes

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.

Don't have a publication yet?

Launch a Fanvaiy publication in minutes, then build any frontend you like on top of it.

Start a publication